getProperties() {
return List.of();
}
public abstract Class
getPropertyClass();
- public abstract String getDescription();
-
//TODO remove in a next step (as soon as interventions impl is done in FE for new props
public Map getDefaultProperties() {
HashMap map = new HashMap<>();
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/ConfigSection.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/ConfigSection.java
new file mode 100644
index 00000000..0190ddd1
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/ConfigSection.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.properties.model;
+
+public class ConfigSection extends Value {
+ public ConfigSection(String id) {
+ super(id);
+ }
+
+ @Override
+ public Class getValueType() {
+ return Void.class;
+ }
+
+ @Override
+ public String getType() {
+ return "GROUPING";
+ }
+}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/IntegerRange.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/IntegerRange.java
new file mode 100644
index 00000000..0f1fb5b0
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/IntegerRange.java
@@ -0,0 +1,51 @@
+package io.redlink.more.studymanager.core.properties.model;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Objects;
+
+public class IntegerRange {
+
+ // Defines the lower set value of the range
+ private final int lower;
+ // Defines the upper set value of the range
+ private final int upper;
+
+ @JsonCreator
+ public IntegerRange(
+ @JsonProperty("lower") @JsonAlias({"min"}) int lower,
+ @JsonProperty("upper") @JsonAlias({"max"}) int upper) {
+ this.lower = lower;
+ this.upper = upper;
+ }
+
+ public int getLower() {
+ return lower;
+ }
+
+ public int getUpper() {
+ return upper;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ IntegerRange that = (IntegerRange) o;
+ return lower == that.lower && upper == that.upper;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(lower, upper);
+ }
+
+ @Override
+ public String toString() {
+ return "IntegerRange{" +
+ "lower=" + lower +
+ ", upper=" + upper +
+ '}';
+ }
+}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/IntegerRangeValue.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/IntegerRangeValue.java
new file mode 100644
index 00000000..aecc1014
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/IntegerRangeValue.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.properties.model;
+
+import io.redlink.more.studymanager.core.validation.ValidationIssue;
+
+public class IntegerRangeValue extends Value {
+
+ // Defines the minimum value of the range, not equal to IntegerRange.lower which defines the user set lower bound
+ private int min = 0;
+ // Defines the maximum value of the range, not equal to IntegerRange.upper which defines the user set upper bound
+ private int max = Integer.MAX_VALUE;
+
+ public IntegerRangeValue(String id) {
+ super(id);
+ }
+
+ @Override
+ public String getType() {
+ return "INTEGER_RANGE";
+ }
+
+ @Override
+ public Class getValueType() {
+ return IntegerRange.class;
+ }
+
+ @Override
+ public ValidationIssue doValidate(IntegerRange range) {
+ if (range != null && range.getLower() > range.getUpper()) {
+ return ValidationIssue.error(this, "Lower bound of RangeValue MUST NOT be higer as the upper bound (lower:" + range.getLower() + ", upper: " + range.getUpper() + ")");
+ }
+ if (range != null && (range.getLower() < getMin() || range.getUpper() > getMax())) {
+ return ValidationIssue.error(this, "Value must between " + getMin() + " and " + getMax());
+ }
+ return ValidationIssue.NONE;
+ }
+
+ public int getMin() {
+ return min;
+ }
+
+ public IntegerRangeValue setMin(int min) {
+ this.min = min;
+ return this;
+ }
+
+ public int getMax() {
+ return max;
+ }
+
+ public IntegerRangeValue setMax(int max) {
+ this.max = max;
+ return this;
+ }
+
+}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/NamedIntegerRange.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/NamedIntegerRange.java
new file mode 100644
index 00000000..54ccd449
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/NamedIntegerRange.java
@@ -0,0 +1,42 @@
+package io.redlink.more.studymanager.core.properties.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+
+import java.util.Objects;
+
+public class NamedIntegerRange extends IntegerRange {
+
+ private String name;
+
+ @JsonCreator
+ public NamedIntegerRange(String name, int lower, int upper) {
+ super(lower, upper);
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ if (!super.equals(o)) return false;
+ NamedIntegerRange that = (NamedIntegerRange) o;
+ return Objects.equals(name, that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), name);
+ }
+
+ @Override
+ public String toString() {
+ return "NamedIntegerRange{" +
+ "name='" + name + '\'' +
+ "lower=" + getLower() +
+ ", upper=" + getUpper() +
+ '}';
+ }
+}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/NamedIntegerRangeValue.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/NamedIntegerRangeValue.java
new file mode 100644
index 00000000..5a67971d
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/NamedIntegerRangeValue.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.properties.model;
+
+import io.redlink.more.studymanager.core.validation.ValidationIssue;
+
+public class NamedIntegerRangeValue extends Value {
+
+ private int min = 0;
+ private int max = Integer.MAX_VALUE;
+
+ public NamedIntegerRangeValue(String id) {
+ super(id);
+ }
+
+ @Override
+ public String getType() {
+ return "INTEGER_RANGE";
+ }
+
+ @Override
+ public Class getValueType() {
+ return NamedIntegerRange.class;
+ }
+
+ @Override
+ public ValidationIssue doValidate(NamedIntegerRange namedRange) {
+ if(namedRange != null && namedRange.getName() == null || namedRange.getName().isBlank()) {
+ return ValidationIssue.error(this, "The name of the RangeValue MUST NOT be blank");
+ }
+ if(namedRange != null && namedRange.getLower() > namedRange.getUpper()) {
+ return ValidationIssue.error(this, "Lower bound of RangeValue MUST NOT be higer as the upper bound (lower:" + namedRange.getLower() + ", upper: " + namedRange.getUpper() + ")");
+ }
+ if (namedRange != null && (namedRange.getLower() < getMin() || namedRange.getUpper() > getMax()) ) {
+ return ValidationIssue.error(this, "Value must between " + getMin() + " and " + getMax());
+ }
+ return ValidationIssue.NONE;
+ }
+
+ public int getMin() {
+ return min;
+ }
+
+ public NamedIntegerRangeValue setMin(int min) {
+ this.min = min;
+ return this;
+ }
+
+ public int getMax() {
+ return max;
+ }
+
+ public NamedIntegerRangeValue setMax(int max) {
+ this.max = max;
+ return this;
+ }
+
+}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/StringTemplateValue.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/StringTemplateValue.java
new file mode 100644
index 00000000..fdd6a381
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/StringTemplateValue.java
@@ -0,0 +1,57 @@
+package io.redlink.more.studymanager.core.properties.model;
+
+import io.redlink.more.studymanager.core.validation.ValidationIssue;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Type that allows to refer other values via <id>. Before saving those MUST BE replaced with the actual values.
+ * The validation will fail if the stored value contains any templates (<id>)
+ */
+public class StringTemplateValue extends StringValue {
+
+
+ private static final Pattern TEMPLATE_PATTERN = Pattern.compile("<[(^>)]+>");
+ private Set allowList;
+
+ public StringTemplateValue(String id) {
+ this(id, null);
+ }
+
+ public StringTemplateValue(String id, Set allowList) {
+ super(id);
+ this.allowList = allowList;
+ }
+
+ @Override
+ public String getType() {
+ return "STRINGTEMPLATE";
+ }
+
+ protected ValidationIssue doValidate(String value) {
+ if (value == null) {
+ return super.doValidate(value);
+ }
+ if (allowList != null) { //check that only allowed templates are used
+ int start = 0;
+ Matcher m = TEMPLATE_PATTERN.matcher(value);
+ Set notAllowed = new HashSet<>();
+ while (m.find(start)) {
+ String template = m.group(1);
+ start = m.end();
+ if (!allowList.contains(template)) {
+ notAllowed.add(template);
+ }
+ }
+ if (!notAllowed.isEmpty()) {
+ return ValidationIssue.error(this, String.format(
+ "The value contains the unknown templates %s (allowed are: %s)!", notAllowed, allowList));
+ }
+ }
+ return super.doValidate(value);
+ }
+
+}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/Value.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/Value.java
index 3f3fab1c..6f01a905 100644
--- a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/Value.java
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/Value.java
@@ -9,10 +9,12 @@
package io.redlink.more.studymanager.core.properties.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.databind.ObjectMapper;
import io.redlink.more.studymanager.core.exception.ValueCastException;
import io.redlink.more.studymanager.core.exception.ValueNonNullException;
import io.redlink.more.studymanager.core.properties.ComponentProperties;
import io.redlink.more.studymanager.core.validation.ValidationIssue;
+
import java.util.function.Function;
public abstract class Value {
@@ -23,6 +25,7 @@ public abstract class Value {
private boolean required = false;
private boolean immutable = false;
private Function validationFunction = (T t) -> ValidationIssue.NONE;
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public Value(String id) {
this.id = id;
@@ -55,8 +58,8 @@ protected ValidationIssue doValidate(T t) {
public T getValue(ComponentProperties properties) {
if (properties.containsKey(id)) {
try {
- return getValueType().cast(properties.get(id));
- } catch (ClassCastException e) {
+ return OBJECT_MAPPER.convertValue(properties.get(id), getValueType());
+ } catch (ClassCastException | IllegalArgumentException e) {
throw new ValueCastException(this, getValueType());
}
} else {
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/ValueGroup.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/ValueGroup.java
new file mode 100644
index 00000000..0e8e4975
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/properties/model/ValueGroup.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.properties.model;
+
+public class ValueGroup extends Value {
+ public ValueGroup(String id) {
+ super(id);
+ }
+
+ @Override
+ public Class getValueType() {
+ return Void.class;
+ }
+
+ @Override
+ public String getType() {
+ return "GROUPING";
+ }
+}
diff --git a/studymanager-goaltemplates/README.md b/studymanager-goaltemplates/README.md
new file mode 100644
index 00000000..42c2e31b
--- /dev/null
+++ b/studymanager-goaltemplates/README.md
@@ -0,0 +1,2 @@
+# More Studymanager Goal Templates
+
diff --git a/studymanager-goaltemplates/pom.xml b/studymanager-goaltemplates/pom.xml
new file mode 100644
index 00000000..e6352814
--- /dev/null
+++ b/studymanager-goaltemplates/pom.xml
@@ -0,0 +1,61 @@
+
+
+ 4.0.0
+
+
+ io.redlink.more
+ studymanager-parent
+ 1.0.${revision}${sha1}${changelist}
+
+
+ studymanager-goaltemplates
+ More Study Manager - Goal Templates
+
+
+
+ io.redlink.more
+ studymanager-core
+ ${project.version}
+ compile
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.google.code.gson
+ gson
+ 2.13.2
+
+
+ org.springframework.boot
+ spring-boot-properties-migrator
+ runtime
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/AbstractAmountOfGoalTemplateFactory.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/AbstractAmountOfGoalTemplateFactory.java
new file mode 100644
index 00000000..733e5b76
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/AbstractAmountOfGoalTemplateFactory.java
@@ -0,0 +1,84 @@
+package io.redlink.more.studymanager.component.goaltemplate;
+
+import io.redlink.more.studymanager.core.component.GoalTemplate;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
+import io.redlink.more.studymanager.core.measurement.Measurement;
+import io.redlink.more.studymanager.core.measurement.MeasurementSet;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.model.ConfigSection;
+import io.redlink.more.studymanager.core.properties.model.IntegerRange;
+import io.redlink.more.studymanager.core.properties.model.IntegerRangeValue;
+import io.redlink.more.studymanager.core.properties.model.StringValue;
+import io.redlink.more.studymanager.core.properties.model.Value;
+import io.redlink.more.studymanager.core.properties.model.ValueGroup;
+
+import java.util.Objects;
+import java.util.Set;
+
+public abstract class AbstractAmountOfGoalTemplateFactory, P extends GoalTemplateProperties> extends GoalTemplateFactory {
+
+ public static final String FIELD_AMOUNT = "amount";
+ public static final String FIELD_UNIT = "unit";
+ public static final String FIELD_TARGET_AMOUNT = "targetAmount";
+ public static final String FIELD_TARGET_DAYS_IN_WEEK = "targetDays";
+
+ private static final MeasurementSet measurements = new MeasurementSet(
+ "SELF_ASSESSMENT", Set.of(
+ new Measurement(FIELD_GOAL_KIND, Measurement.Type.STRING),
+ new Measurement(FIELD_GOAL_CATEGORY, Measurement.Type.STRING),
+ new Measurement(FIELD_AMOUNT, Measurement.Type.INTEGER),
+ new Measurement(FIELD_UNIT, Measurement.Type.STRING),
+ new Measurement(FIELD_TARGET_AMOUNT, Measurement.Type.INTEGER),
+ new Measurement(FIELD_TARGET_DAYS_IN_WEEK, Measurement.Type.INTEGER)));
+
+ protected static final String AMOUNT_OF_PROPERTY_PREFIX = GOAL_TEMPLATE_PROPERTY_PREFIX + "amountOfGoal.";
+
+ protected static final Value CONFIG_SECTION_CONFIGURATION = new ConfigSection("configuration")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "section.configuration.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "section.configuration.description")
+ .setImmutable(true);
+ protected static final Value CONFIG_SECTION_GOAL_CONFIGURATION = new ConfigSection("goal-configuration")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "section.goal.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "section.goal.description")
+ .setImmutable(true);
+ protected static final Value CONFIG_SECTION_STATUS = new ConfigSection("status-texts")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "section.status-texts.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "section.status-texts.description")
+ .setImmutable(true);
+ protected static final Value CONFIG_SECTION_SELF_REPORT = new ConfigSection("self-report")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "section.self-report.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "section.self-report.description")
+ .setImmutable(true);
+
+ protected static final Value CONFIGS_GOAL_AMOUNT_GROUP = new ValueGroup("goal")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "goal.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "goal.description")
+ .setImmutable(true);
+ protected static final Value CONFIGS_GOAL_AMOUNT_VALUE = new IntegerRangeValue("goal.amount")
+ .setMin(1)
+ .setMax(Integer.MAX_VALUE)
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "goal.amount.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "goal.amount.description")
+ .setRequired(true);
+ protected static final Value CONFIGS_GOAL_AMOUNT_UNIT = new StringValue("goal.unit")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "goal.unit.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "goal.unit.description")
+ .setRequired(true);
+
+ protected final String id;
+
+ AbstractAmountOfGoalTemplateFactory(String id){
+ this.id = Objects.requireNonNull(id);
+ }
+
+ @Override
+ public final String getId() {
+ return id;
+ }
+
+ @Override
+ public final MeasurementSet getMeasurementSet() {
+ return measurements;
+ }
+
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/AmoutOfGoalTemplate.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/AmoutOfGoalTemplate.java
new file mode 100644
index 00000000..b23ff9ad
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/AmoutOfGoalTemplate.java
@@ -0,0 +1,26 @@
+package io.redlink.more.studymanager.component.goaltemplate;
+
+import io.redlink.more.studymanager.core.component.GoalTemplate;
+import io.redlink.more.studymanager.core.component.Observation;
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.ObservationProperties;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+
+public class AmoutOfGoalTemplate extends GoalTemplate {
+
+
+ protected AmoutOfGoalTemplate(MoreGoalTemplateSDK sdk, C properties) throws ConfigurationValidationException {
+ super(sdk, properties);
+ }
+
+
+ @Override
+ public void activate() {
+ }
+
+ @Override
+ public void deactivate() {
+ }
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/EatAmountOfGoalTemplateFactory.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/EatAmountOfGoalTemplateFactory.java
new file mode 100644
index 00000000..1612627d
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/EatAmountOfGoalTemplateFactory.java
@@ -0,0 +1,82 @@
+package io.redlink.more.studymanager.component.goaltemplate;
+
+import io.redlink.more.studymanager.core.component.GoalTemplate;
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.factory.ComponentFactory;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.model.StringTemplateValue;
+import io.redlink.more.studymanager.core.properties.model.StringTextValue;
+import io.redlink.more.studymanager.core.properties.model.StringValue;
+import io.redlink.more.studymanager.core.properties.model.Value;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+
+import static io.redlink.more.studymanager.component.goaltemplate.AbstractAmountOfGoalTemplateFactory.*;
+
+import java.util.List;
+import java.util.Set;
+
+public class EatAmountOfGoalTemplateFactory extends AbstractAmountOfGoalTemplateFactory,GoalTemplateProperties> {
+
+
+ public EatAmountOfGoalTemplateFactory() {
+ super("eat-amount-of");
+ }
+
+ public List getProperties() {
+ return List.of(
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_CONFIGURATION,
+ GoalTemplateFactory.APP_TITLE,
+ GoalTemplateFactory.APP_DESCRIPTION,
+ GoalTemplateFactory.GOAL_TITLE_STATE,
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_GOAL_CONFIGURATION,
+ new StringTemplateValue(
+ "goal-preview",
+ Set.of(
+ CONFIGS_GOAL_AMOUNT_VALUE.getId(),
+ CONFIGS_GOAL_AMOUNT_UNIT.getId(),
+ DAYS_OF_WEEK.getId()
+ ))
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "goal-preview.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "goal-preview.description")
+ .setDefaultValue("Ich esse mindestens Portionen [an Tagen der Woche]")
+ .setImmutable(true),
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_GROUP,
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_VALUE,
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_UNIT,
+ GoalTemplateFactory.DAYS_OF_WEEK,
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_STATUS,
+ new StringTextValue("status-100-reached")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-100-reached.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-100-reached.description")
+ .setDefaultValue("Du hast dein Tagesziel erfolgreich gemeistert. Weiter so!"),
+ new StringTextValue("status-75-reached")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-75-reached.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-75-reached.description")
+ .setDefaultValue("Dein Ziel ist zum greifen nah. Ein bisschen mehr und Du hast es geschafft!"),
+ new StringTextValue("status-not-reached")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-not-reached.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-not-reached.description")
+ .setDefaultValue("Du bist auf den richtigen Weg. Jede Portion zählt für Dein wohlbefinden."),
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_SELF_REPORT,
+ new StringTextValue("self-report-question")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "self-report-question.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "self-report-question.description")
+ .setDefaultValue("Wie viele Portionen hast Du heute gegessen? Bitte trage den Wert ein."),
+ GoalTemplateFactory.SELF_REPORT_TIME_EVENING
+ );
+ }
+
+ @Override
+ public Class getPropertyClass() {
+ return GoalTemplateProperties.class;
+ }
+
+ @Override
+ public AmoutOfGoalTemplate create(MoreGoalTemplateSDK sdk, GoalTemplateProperties properties) throws ConfigurationValidationException {
+ return new AmoutOfGoalTemplate<>(sdk, properties);
+ }
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/ReduceAmountOfGoalTemplateFactory.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/ReduceAmountOfGoalTemplateFactory.java
new file mode 100644
index 00000000..a50dac13
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/ReduceAmountOfGoalTemplateFactory.java
@@ -0,0 +1,82 @@
+package io.redlink.more.studymanager.component.goaltemplate;
+
+import io.redlink.more.studymanager.core.component.GoalTemplate;
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.factory.ComponentFactory;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.model.StringTemplateValue;
+import io.redlink.more.studymanager.core.properties.model.StringTextValue;
+import io.redlink.more.studymanager.core.properties.model.StringValue;
+import io.redlink.more.studymanager.core.properties.model.Value;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+
+import java.util.List;
+import java.util.Set;
+
+public class ReduceAmountOfGoalTemplateFactory extends AbstractAmountOfGoalTemplateFactory, GoalTemplateProperties> {
+
+ public ReduceAmountOfGoalTemplateFactory(){
+ super("reduce-amount-of");
+ }
+
+ public List getProperties() {
+ return List.of(
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_CONFIGURATION,
+ GoalTemplateFactory.APP_TITLE,
+ GoalTemplateFactory.APP_DESCRIPTION,
+ GoalTemplateFactory.GOAL_TITLE_STATE,
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_GOAL_CONFIGURATION,
+ new StringTemplateValue(
+ "goal-preview",
+ Set.of(
+ CONFIGS_GOAL_AMOUNT_VALUE.getId(),
+ CONFIGS_GOAL_AMOUNT_UNIT.getId(),
+ DAYS_OF_WEEK.getId()
+ ))
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "goal-preview.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "goal-preview.description")
+ .setDefaultValue("Ich maximal [an maximal Tagen in der Woche]")
+ .setImmutable(true),
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_GROUP,
+ new StringValue("goal.activity")
+ .setName(GOAL_TEMPLATE_PROPERTY_PREFIX + "reduceAmountOf.goal.activity.name")
+ .setDescription(GOAL_TEMPLATE_PROPERTY_PREFIX + "reduceAmountOf.goal.activity.description")
+ .setDefaultValue("konsumiere")
+ .setRequired(true),
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_VALUE,
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_UNIT,
+ GoalTemplateFactory.DAYS_OF_WEEK,
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_STATUS,
+ new StringTextValue("status-day-not-consumed")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-day-not-consumed.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-day-not-consumed.description")
+ .setDefaultValue("Starker Tag! Du hast heute nichts konsumiert."),
+ new StringTextValue("status-day-under-limit")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-day-under-limit.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-day-under-limit.description")
+ .setDefaultValue("Du gemacht. Du bist im Ziel geblieben und hast die Kontrolle behalten. Weiter so!"),
+ new StringTextValue("status-day-over-limit")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-day-over-limit.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-day-over-limit.description")
+ .setDefaultValue("Du hast heute mehr konsumiert als ausgemacht. Bleib dran. Morgen geht es wieder besser."),
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_SELF_REPORT,
+ new StringTextValue("self-report-question")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "self-report-question.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "self-report-question.description")
+ .setDefaultValue("Wie viele Einheiten hast Du heute ? Bitte trage den Wert ein."),
+ GoalTemplateFactory.SELF_REPORT_TIME_EVENING
+ );
+ }
+
+
+ @Override
+ public AmoutOfGoalTemplate create(MoreGoalTemplateSDK sdk, GoalTemplateProperties properties) throws ConfigurationValidationException {
+ return new AmoutOfGoalTemplate<>(sdk, properties);
+ }
+
+
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/TrinkAmountOfGoalTemplateFactory.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/TrinkAmountOfGoalTemplateFactory.java
new file mode 100644
index 00000000..e20606c8
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/component/goaltemplate/TrinkAmountOfGoalTemplateFactory.java
@@ -0,0 +1,79 @@
+package io.redlink.more.studymanager.component.goaltemplate;
+
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.model.StringTemplateValue;
+import io.redlink.more.studymanager.core.properties.model.StringTextValue;
+import io.redlink.more.studymanager.core.properties.model.StringValue;
+import io.redlink.more.studymanager.core.properties.model.Value;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+
+import java.util.List;
+import java.util.Set;
+
+
+public class TrinkAmountOfGoalTemplateFactory extends AbstractAmountOfGoalTemplateFactory, GoalTemplateProperties> {
+
+ public TrinkAmountOfGoalTemplateFactory() {
+ super("trink-amount-of");
+ }
+
+ public List getProperties() {
+ return List.of(
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_CONFIGURATION,
+ GoalTemplateFactory.APP_TITLE,
+ GoalTemplateFactory.APP_DESCRIPTION,
+ GoalTemplateFactory.GOAL_TITLE_STATE,
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_GOAL_CONFIGURATION,
+ new StringTemplateValue(
+ "goal-preview",
+ Set.of(
+ CONFIGS_GOAL_AMOUNT_VALUE.getId(),
+ CONFIGS_GOAL_AMOUNT_UNIT.getId(),
+ DAYS_OF_WEEK.getId()
+ ))
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "goal-preview.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "goal-preview.description")
+ .setDefaultValue("Ich trinke mindestens [an Tagen der Woche]")
+ .setImmutable(true),
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_GROUP,
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_VALUE,
+ AbstractAmountOfGoalTemplateFactory.CONFIGS_GOAL_AMOUNT_UNIT,
+ GoalTemplateFactory.DAYS_OF_WEEK,
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_STATUS,
+ new StringTextValue("status-100-reached")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-100-reached.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-100-reached.description")
+ .setDefaultValue("Du hast dein Tagesziel erfolgreich gemeistert. Weiter so!"),
+ new StringTextValue("status-75-reached")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-75-reached.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-75-reached.description")
+ .setDefaultValue("Dein Ziel ist zum greifen nah. Ein bisschen und Du hast es geschafft!"),
+ new StringTextValue("status-not-reached")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "status-not-reached.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "status-not-reached.description")
+ .setDefaultValue("Du bist auf dem richtigen Weg. Jede Schluck zählt für Dein wohlbefinden."),
+
+ AbstractAmountOfGoalTemplateFactory.CONFIG_SECTION_SELF_REPORT,
+ new StringTextValue("self-report-question")
+ .setName(AMOUNT_OF_PROPERTY_PREFIX + "self-report-question.name")
+ .setDescription(AMOUNT_OF_PROPERTY_PREFIX + "self-report-question.description")
+ .setDefaultValue("Wie viele Einheiten hast Du heute getrunken? Bitte trage den Wert ein."),
+ GoalTemplateFactory.SELF_REPORT_TIME_EVENING
+ );
+ }
+
+ @Override
+ public Class getPropertyClass() {
+ return GoalTemplateProperties.class;
+ }
+
+ @Override
+ public AmoutOfGoalTemplate create(MoreGoalTemplateSDK sdk, GoalTemplateProperties properties) throws ConfigurationValidationException {
+ return new AmoutOfGoalTemplate<>(sdk, properties);
+ }
+
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/component/GoalTemplate.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/component/GoalTemplate.java
new file mode 100644
index 00000000..c378c855
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/component/GoalTemplate.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.component;
+
+import io.redlink.more.studymanager.core.datavalidity.ObservationDataState;
+import io.redlink.more.studymanager.core.datavalidity.ObservationDataSummary;
+import io.redlink.more.studymanager.core.datavalidity.ObservationValidationResult;
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.io.TimeRange;
+import io.redlink.more.studymanager.core.measurement.MeasurementSet;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.ObservationProperties;
+import io.redlink.more.studymanager.core.sdk.MoreGoalSDK;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+import io.redlink.more.studymanager.core.sdk.MoreObservationSDK;
+import io.redlink.more.studymanager.core.ui.DataView;
+import io.redlink.more.studymanager.core.ui.DataViewInfo;
+
+import java.time.Instant;
+
+public abstract class GoalTemplate extends Component {
+
+ protected final MoreGoalTemplateSDK sdk;
+ protected GoalTemplate(MoreGoalTemplateSDK sdk, C properties) throws ConfigurationValidationException {
+ super(properties);
+ this.sdk = sdk;
+ }
+
+ @Override
+ public void activate() {
+ // no action
+ }
+
+ @Override
+ public void deactivate() {
+ // no action
+ }
+
+
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/factory/GoalTemplateFactory.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/factory/GoalTemplateFactory.java
new file mode 100644
index 00000000..246422e3
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/factory/GoalTemplateFactory.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.factory;
+
+import io.redlink.more.studymanager.core.component.GoalTemplate;
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.io.Visibility;
+import io.redlink.more.studymanager.core.measurement.MeasurementSet;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.model.BooleanValue;
+import io.redlink.more.studymanager.core.properties.model.IntegerRange;
+import io.redlink.more.studymanager.core.properties.model.IntegerRangeValue;
+import io.redlink.more.studymanager.core.properties.model.StringTextValue;
+import io.redlink.more.studymanager.core.properties.model.StringValue;
+import io.redlink.more.studymanager.core.properties.model.Value;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+
+public abstract class GoalTemplateFactory, P extends GoalTemplateProperties> extends ComponentFactory {
+
+ private static final Visibility GOAL_TEMPLATE_VISIBILITY = new Visibility(false, false);
+
+ public abstract C create(MoreGoalTemplateSDK sdk, P properties) throws ConfigurationValidationException;
+
+ @Override
+ public Class getPropertyClass() {
+ return GoalTemplateProperties.class;
+ }
+
+ public abstract MeasurementSet getMeasurementSet();
+
+ public final Visibility getVisibility() {
+ return GOAL_TEMPLATE_VISIBILITY;
+ }
+
+ /**
+ * Getter for the propertiy to internationalize the title
+ * ({@link #GOAL_TEMPLATE_FACTORY_PREFIX} + {@link #getId()} + ".name")
+ * @return the internationalisation property for the title
+ */
+ public final String getTitle() {
+ return GOAL_TEMPLATE_FACTORY_PREFIX + getId() + ".name";
+ }
+
+ /**
+ * Getter for the propertiy to internationalize the description
+ * ({@link #GOAL_TEMPLATE_FACTORY_PREFIX} + {@link #getId()} + ".description")
+ * @return the internationalisation property for the description
+ */
+ public final String getDescription() {
+ return GOAL_TEMPLATE_FACTORY_PREFIX + getId() + ".description";
+ }
+
+ //Constants
+
+ /**
+ * The kind of the goal (outcome, behavioral)
+ */
+ public static final String FIELD_GOAL_KIND = "goal-kind";
+ /**
+ * The key of the goal category
+ */
+ public static final String FIELD_GOAL_CATEGORY = "goal-category";
+
+
+ /**
+ * Prefix to be used for all goal template related configurations
+ */
+ public static final String GOAL_TEMPLATE_PREFIX = "goaltemplate.";
+ /**
+ * Prefix to be used for all goal template factory related configurations (e.g. title, description)
+ */
+ public static final String GOAL_TEMPLATE_FACTORY_PREFIX = "goaltemplate.factory.";
+
+ /**
+ * Property prefix to be used for all goal template properties ('goaltemplate.property.
')
+ */
+ public static final String GOAL_TEMPLATE_PROPERTY_PREFIX = "goaltemplate.property.";
+ /**
+ * GoalTemplate property prefix used by all globally defined properties
+ */
+ private static final String GLOBAL_PROPERTY_PREFIX = GOAL_TEMPLATE_PROPERTY_PREFIX + "global.";
+
+ /**
+ * Used to configure the title of the GoalTemplate as shown in the application
+ */
+ public static final Value APP_TITLE = new StringValue("app-title")
+ .setName(GLOBAL_PROPERTY_PREFIX + "appTitle.name")
+ .setDescription(GLOBAL_PROPERTY_PREFIX + "appTitle.description")
+ .setRequired(true);
+ /**
+ * Used to configure the custom description of the GoalTemplate as shown in the application
+ */
+ public static final Value APP_DESCRIPTION = new StringTextValue("app-description")
+ .setName(GLOBAL_PROPERTY_PREFIX + "appDescription.name")
+ .setDescription(GLOBAL_PROPERTY_PREFIX + "appDescription.description")
+ .setRequired(false);
+
+
+ /**
+ * Allows to define that a goal is aktive on x days of the week (e.g. 5/7 days)
+ */
+ public static final Value DAYS_OF_WEEK = new IntegerRangeValue("days-of-week")
+ .setMin(1) //at least at one day of the week
+ .setMax(7) //a week has only 7 days
+ .setName(GLOBAL_PROPERTY_PREFIX + "days-of-week.name")
+ .setDescription(GLOBAL_PROPERTY_PREFIX + "days-of-week.description")
+ .setDefaultValue(new IntegerRange(7, 7));
+
+ /**
+ * User can assign a custom title to the GoalTemplate
+ */
+ public static final Value GOAL_TITLE_STATE = new BooleanValue("goal-title-state")
+ .setName(GLOBAL_PROPERTY_PREFIX + "goalTitleState.name")
+ .setDescription(GLOBAL_PROPERTY_PREFIX + "goalTitleState.description")
+ .setDefaultValue(false);
+ /**
+ * User can create multiple instance of the same GoalTemplate
+ */
+ public static final Value ALLOW_INSTANCES_STATE = new BooleanValue("instance-state")
+ .setName(GLOBAL_PROPERTY_PREFIX + "instanceState.name")
+ .setDescription(GLOBAL_PROPERTY_PREFIX + "instanceState.description")
+ .setDefaultValue(false);
+
+ /**
+ * Allows to configure a self report time.
+ */
+ public static final Value SELF_REPORT_TIME = new StringValue("self-report-time")
+ .setName(GLOBAL_PROPERTY_PREFIX + "self-report-time.name")
+ .setDescription(GLOBAL_PROPERTY_PREFIX + "self-report-time.description")
+ .setDefaultValue("Abends"); //use evening as default FIXME: change to the correct value
+ /**
+ * Sets the self report time to evening. Just informative. Can not be changed by via configuration
+ */
+ public static final Value SELF_REPORT_TIME_EVENING = new StringValue("self-report-time")
+ .setName(GLOBAL_PROPERTY_PREFIX + "self-report-time.name")
+ .setDescription(GLOBAL_PROPERTY_PREFIX + "self-report-time.description")
+ .setDefaultValue("Abends") // FIXME: change to the correct value
+ .setImmutable(true);
+
+
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/properties/GoalTemplateProperties.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/properties/GoalTemplateProperties.java
new file mode 100644
index 00000000..0ea42fd4
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/properties/GoalTemplateProperties.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.properties;
+
+import java.util.Map;
+
+public class GoalTemplateProperties extends ComponentProperties {
+ public GoalTemplateProperties() {
+ }
+
+ public GoalTemplateProperties(Map map) {
+ super(map);
+ }
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/sdk/MoreGoalSDK.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/sdk/MoreGoalSDK.java
new file mode 100644
index 00000000..434948d4
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/sdk/MoreGoalSDK.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.sdk;
+
+import io.redlink.more.studymanager.core.properties.GoalProperties;
+
+import java.util.Map;
+import java.util.Optional;
+
+public interface MoreGoalSDK extends MorePlatformSDK {
+
+ int getGoalId();
+}
diff --git a/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/sdk/MoreGoalTemplateSDK.java b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/sdk/MoreGoalTemplateSDK.java
new file mode 100644
index 00000000..935481e0
--- /dev/null
+++ b/studymanager-goaltemplates/src/main/java/io/redlink/more/studymanager/core/sdk/MoreGoalTemplateSDK.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.core.sdk;
+
+import io.redlink.more.studymanager.core.io.TimeRange;
+import io.redlink.more.studymanager.core.properties.ObservationProperties;
+import io.redlink.more.studymanager.core.ui.DataViewData;
+import io.redlink.more.studymanager.core.ui.ViewConfig;
+
+import java.util.Map;
+import java.util.Optional;
+
+public interface MoreGoalTemplateSDK extends MorePlatformSDK {
+
+ int getTemplateId();
+}
diff --git a/studymanager-goaltemplates/src/test/java/io/redlink/more/studymanager/component/goaltemplate/EatAmountOfGoalTemplateFactoryTest.java b/studymanager-goaltemplates/src/test/java/io/redlink/more/studymanager/component/goaltemplate/EatAmountOfGoalTemplateFactoryTest.java
new file mode 100644
index 00000000..2b54522e
--- /dev/null
+++ b/studymanager-goaltemplates/src/test/java/io/redlink/more/studymanager/component/goaltemplate/EatAmountOfGoalTemplateFactoryTest.java
@@ -0,0 +1,45 @@
+package io.redlink.more.studymanager.component.goaltemplate;
+
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class EatAmountOfGoalTemplateFactoryTest {
+
+ private EatAmountOfGoalTemplateFactory factory;
+
+ @BeforeEach
+ public void init() {
+ factory = new EatAmountOfGoalTemplateFactory();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ factory = null;
+ }
+
+ @Test
+ public void testPropertyValidation(){
+ Map properties = new HashMap<>();
+ properties.put("app-title", "title");
+ properties.put("app-description", "description");
+ properties.put("days-of-week", Map.of("lower", 7, "upper", 7));
+ properties.put("goal-preview", "Ich esse mindestens Portionen [ an Tagen der Woche].");
+ properties.put("goal-title-state", true);
+ properties.put("goal.amount", Map.of("lower", 1, "upper", 1));
+ properties.put("goal.unit", "Portionen Obst");
+ properties.put("self-report-question", "Wie viele Portionen hast Du heute gegessen? Bitte trage den Wert ein.");
+ properties.put("self-report-time", "Abends");
+ properties.put("status-75-reached", "Dein Ziel ist zum greifen nah. Ein bisschen mehr und Du hast es geschafft!");
+ properties.put("status-100-reached", "Du hast dein Tagesziel erfolgreich gemeistert. Weiter so!");
+ properties.put("status-not-reached", "Du bist auf den richtigen Weg. Jede Portion zählt für Dein wohlbefinden.");
+
+ //Validate that this works without exception
+ factory.validate(new GoalTemplateProperties(properties));
+
+ }
+}
diff --git a/studymanager-services/pom.xml b/studymanager-services/pom.xml
index 080db993..ea092a47 100644
--- a/studymanager-services/pom.xml
+++ b/studymanager-services/pom.xml
@@ -33,6 +33,11 @@
studymanager-intervention
${project.version}
+
+ io.redlink.more
+ studymanager-goaltemplates
+ ${project.version}
+
com.fasterxml.jackson.datatype
jackson-datatype-jsr310
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/action/ActionService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/action/ActionService.java
index 1f811dc7..98cdb24a 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/action/ActionService.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/action/ActionService.java
@@ -16,9 +16,13 @@
import io.redlink.more.studymanager.utils.LoggingUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.BeanNotOfRequiredTypeException;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
@Component
@@ -28,7 +32,7 @@ public class ActionService {
private final InterventionService interventionService;
- private final Map actionFactories;
+ private final ApplicationContext applicationContext;
private final MoreSDK moreSDK;
@@ -36,13 +40,13 @@ public class ActionService {
public ActionService(
InterventionService interventionService,
- Map actionFactories,
MoreSDK moreSDK,
- ActionWorker worker) {
+ ActionWorker worker,
+ ApplicationContext applicationContext) {
this.interventionService = interventionService;
- this.actionFactories = actionFactories;
this.moreSDK = moreSDK;
this.worker = worker;
+ this.applicationContext = applicationContext;
}
public void execute(long studyId, Integer studyGroupId, int interventionId, Set parameters) {
@@ -57,8 +61,7 @@ private void executeAction(long studyId, Integer studyGroupId, int interventionI
io.redlink.more.studymanager.model.Action action) {
try (var ctx = LoggingUtils.createContext()) {
ctx.putAction(action);
- ActionFactory factory = actionFactories.get(action.getType());
-
+ ActionFactory factory = factory(action).orElse(null);
if (factory == null) {
LOGGER.error("Skipping action_{} from intervention_{} in study_{}: No factory found for actionType {}",
action.getActionId(), interventionId, studyId, action.getType());
@@ -83,4 +86,12 @@ private void executeAction(long studyId, Integer studyGroupId, int interventionI
}
}
+ private Optional factory(io.redlink.more.studymanager.model.Action action) {
+ try {
+ return Optional.of(applicationContext.getBean(action.getType(), ActionFactory.class));
+ } catch (NoSuchBeanDefinitionException | BeanNotOfRequiredTypeException e){
+ return Optional.empty();
+ }
+ }
+
}
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/configuration/ComponentFactoriesConfiguration.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/configuration/ComponentFactoriesConfiguration.java
index db790e2f..9ba62ef3 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/configuration/ComponentFactoriesConfiguration.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/configuration/ComponentFactoriesConfiguration.java
@@ -9,6 +9,8 @@
package io.redlink.more.studymanager.configuration;
import io.redlink.more.studymanager.core.factory.ActionFactory;
+import io.redlink.more.studymanager.core.factory.ComponentFactory;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
import io.redlink.more.studymanager.core.factory.ObservationFactory;
import io.redlink.more.studymanager.core.factory.TriggerFactory;
import io.redlink.more.studymanager.properties.ComponentFactoriesProperties;
@@ -24,15 +26,14 @@
import javax.annotation.PostConstruct;
import java.lang.reflect.InvocationTargetException;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Collectors;
+import java.lang.reflect.Modifier;
+import java.util.stream.Stream;
@Configuration
@EnableConfigurationProperties({ComponentFactoriesProperties.class})
public class ComponentFactoriesConfiguration implements BeanFactoryAware {
private final Logger logger = LoggerFactory.getLogger(ComponentFactoriesConfiguration.class);
- private BeanFactory beanFactory;
+ private ConfigurableBeanFactory beanFactory;
private final Reflections reflections;
private final ComponentFactoriesProperties componentFactoriesProperties;
@@ -44,42 +45,32 @@ public ComponentFactoriesConfiguration(ComponentFactoriesProperties componentFac
@Override
public void setBeanFactory(BeanFactory beanFactory) {
- this.beanFactory = beanFactory;
- }
-
- @Bean
- public Map triggerFactoryMap() {
- Set> triggerFactories = reflections.getSubTypesOf(TriggerFactory.class);
- return triggerFactories.stream().map(this::instantiate)
- .collect(Collectors.toMap(
- (trigger) -> trigger.getId(),
- (trigger) -> trigger
- ));
+ this.beanFactory = (ConfigurableBeanFactory) beanFactory;
}
@PostConstruct
public void onPostConstruct() {
- ConfigurableBeanFactory configurableBeanFactory = (ConfigurableBeanFactory) beanFactory;
- Set> observationFactories = reflections.getSubTypesOf(ObservationFactory.class);
- observationFactories.stream().map(this::instantiate)
+ initAndRegisterFactory(getFactoryImplementations(ObservationFactory.class));
+ initAndRegisterFactory(getFactoryImplementations(TriggerFactory.class));
+ initAndRegisterFactory(getFactoryImplementations(ActionFactory.class));
+ initAndRegisterFactory(getFactoryImplementations(GoalTemplateFactory.class));
+ }
+
+ private > void initAndRegisterFactory(Stream> factories) {
+ factories
+ .map(this::instantiate)
.map(f -> f.init(componentFactoriesProperties.get(f.getId())))
.forEach(m -> {
- logger.trace("Registering observation factory: {}[class:{}, properties:{}]", m.getId(),m.getClass().getName(), m.getProperties());
- configurableBeanFactory.registerSingleton(m.getId(), m);
- }
- );
-
- /*
- Set> triggerFactories = reflections.getSubTypesOf(TriggerFactory.class);
- triggerFactories.stream().map(this::instantiate).forEach(m ->
- configurableBeanFactory.registerSingleton(m.getId(), m)
- );*/
+ logger.trace("Registering ComponentFactory: {} [class:{}, properties:{}]", m.getId(),m.getClass().getName(), m.getProperties());
+ beanFactory.registerSingleton(m.getId(), m);
+ });
+ }
- Set> actionFactories = reflections.getSubTypesOf(ActionFactory.class);
- actionFactories.stream().map(this::instantiate).forEach(m ->
- configurableBeanFactory.registerSingleton(m.getId(), m)
- );
+ private Stream> getFactoryImplementations(Class factoryType) {
+ return reflections.getSubTypesOf(factoryType)
+ .stream()
+ .filter(c -> !Modifier.isAbstract(c.getModifiers()) && !c.isInterface());
}
private T instantiate(Class extends T> c) {
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/GoalAdherenceCheck.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/GoalAdherenceCheck.java
index c163127b..de15664d 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/GoalAdherenceCheck.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/GoalAdherenceCheck.java
@@ -1,6 +1,7 @@
package io.redlink.more.studymanager.model;
import java.time.LocalTime;
+import java.util.Objects;
public class GoalAdherenceCheck {
private Long studyId;
@@ -43,4 +44,16 @@ public GoalAdherenceCheck setTime(LocalTime time) {
this.time = time;
return this;
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ GoalAdherenceCheck that = (GoalAdherenceCheck) o;
+ return Objects.equals(studyId, that.studyId) && Objects.equals(checkId, that.checkId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(studyId, checkId);
+ }
}
\ No newline at end of file
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java
index 1868ea3e..39ddce30 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java
@@ -22,12 +22,18 @@ public class NameValuePairRepository {
private static final String UPSERT_O = "INSERT INTO nvpairs_observations(study_id, observation_id, name, value) VALUES (?,?,?,?) ON CONFLICT(study_id, observation_id, name) DO UPDATE SET value = EXCLUDED.value";
private static final String UPSERT_T = "INSERT INTO nvpairs_triggers(study_id, intervention_id, name, value) VALUES (?,?,?,?) ON CONFLICT(study_id, intervention_id, name) DO UPDATE SET value = EXCLUDED.value";
private static final String UPSERT_A = "INSERT INTO nvpairs_actions(study_id, intervention_id, action_id, name, value) VALUES (?,?,?,?,?) ON CONFLICT(study_id, intervention_id, action_id, name) DO UPDATE SET value = EXCLUDED.value";
+ private static final String UPSERT_GT = "INSERT INTO nvpairs_goaltemplates(study_id, template_id, name, value) VALUES (?,?,?,?) ON CONFLICT(study_id, template_id, name) DO UPDATE SET value = EXCLUDED.value";
+ private static final String UPSERT_G = "INSERT INTO nvpairs_goals(study_id, goal_id, name, value) VALUES (?,?,?,?) ON CONFLICT(study_id, goal_id, name) DO UPDATE SET value = EXCLUDED.value";
private static final String READ_O = "SELECT value FROM nvpairs_observations WHERE study_id = ? AND observation_id = ? AND name = ? LIMIT 1";
private static final String READ_T = "SELECT value FROM nvpairs_triggers WHERE study_id = ? AND intervention_id = ? AND name = ? LIMIT 1";
private static final String READ_A = "SELECT value FROM nvpairs_actions WHERE study_id = ? AND intervention_id = ? AND action_id = ? AND name = ? LIMIT 1";
+ private static final String READ_GT = "SELECT value FROM nvpairs_goaltemplates WHERE study_id = ? AND template_id = ? AND name = ? LIMIT 1";
+ private static final String READ_G = "SELECT value FROM nvpairs_goals WHERE study_id = ? AND goal_id = ? AND name = ? LIMIT 1";
private static final String REMOVE_O = "DELETE FROM nvpairs_observations WHERE study_id = ? AND observation_id = ? AND name = ?";
private static final String REMOVE_T = "DELETE FROM nvpairs_triggers WHERE study_id = ? AND intervention_id = ? AND name = ?";
private static final String REMOVE_A = "DELETE FROM nvpairs_actions WHERE study_id = ? AND intervention_id = ? AND action_id = ? AND name = ?";
+ private static final String REMOVE_GT = "DELETE FROM nvpairs_goaltemplates WHERE study_id = ? AND template_id = ? AND name = ?";
+ private static final String REMOVE_G = "DELETE FROM nvpairs_goals WHERE study_id = ? AND goal_id = ? AND name = ?";
private final JdbcTemplate template;
@@ -47,6 +53,14 @@ public void setActionValue(Long studyId, int interventi
this.template.update(UPSERT_A, studyId, interventionId, actionId, name, SerializationUtils.serialize(value));
}
+ public void setGoalTemplateValue(Long studyId, int templateId, String name, T value) {
+ this.template.update(UPSERT_GT, studyId, templateId, name, SerializationUtils.serialize(value));
+ }
+
+ public void setGoalValue(Long studyId, int goalId, String name, T value) {
+ this.template.update(UPSERT_G, studyId, goalId, name, SerializationUtils.serialize(value));
+ }
+
public Optional getObservationValue(Long studyId, int observationId, String name, Class tClass) {
try {
return Optional.ofNullable(this.template.queryForObject(READ_O,
@@ -77,6 +91,26 @@ public Optional getActionValue(Long studyId, int int
}
}
+ public Optional getGoalTemplateValue(Long studyId, int templateId, String name, Class tClass) {
+ try {
+ return Optional.ofNullable(this.template.queryForObject(READ_GT,
+ (rs, rowNum) -> tClass.cast(SerializationUtils.deserialize(rs.getBytes("value"))),
+ studyId, templateId, name));
+ } catch (EmptyResultDataAccessException e) {
+ return Optional.empty();
+ }
+ }
+
+ public Optional getGoalValue(Long studyId, int goalId, String name, Class tClass) {
+ try {
+ return Optional.ofNullable(this.template.queryForObject(READ_G,
+ (rs, rowNum) -> tClass.cast(SerializationUtils.deserialize(rs.getBytes("value"))),
+ studyId, goalId, name));
+ } catch (EmptyResultDataAccessException e) {
+ return Optional.empty();
+ }
+ }
+
public void removeObservationValue(Long studyId, int observationId, String name) {
this.template.update(REMOVE_O, studyId, observationId, name);
}
@@ -89,6 +123,14 @@ public void removeActionValue(Long studyId, int interventionId, int actionId, St
this.template.update(REMOVE_A, studyId, interventionId, actionId, name);
}
+ public void removeGoalTemplateValue(Long studyId, int templateId, String name) {
+ this.template.update(REMOVE_GT, studyId, templateId, name);
+ }
+
+ public void removeGoalValue(Long studyId, int goalId, String name) {
+ this.template.update(REMOVE_G, studyId, goalId, name);
+ }
+
protected boolean noObservationValues() {
return this.template.queryForObject(
"SELECT count(*) AS c FROM nvpairs_observations", Integer.class) == 0;
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepository.java
index e0fc8c52..95a6177f 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepository.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepository.java
@@ -39,7 +39,7 @@ ON CONFLICT (study_id) DO UPDATE SET
private static final String INSERT_OR_UPDATE_TOPIC = """
INSERT INTO goal_topics(study_id, key, title, description)
VALUES (:study_id, :key, :title, :description)
- ON CONFLICT (study_id, key) DO UPDATE SET
+ ON CONFLICT (study_id, key) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
modified = now()""";
@@ -47,9 +47,12 @@ ON CONFLICT (study_id, key) DO UPDATE SET
private static final String LIST_TOPICS = "SELECT * FROM goal_topics WHERE study_id = ? ORDER BY key";
private static final String DELETE_TOPIC = "DELETE FROM goal_topics WHERE study_id = ? AND key = ?";
- private static final String INSERT_ADHERENCE_CHECK = """
- INSERT INTO goal_adherence_checks(study_id, check_id, title, time)
- VALUES (:study_id, (SELECT COALESCE(MAX(check_id),0)+1 FROM goal_adherence_checks WHERE study_id = :study_id), :title, :time)""";
+ private static final String UPSERT_ADHERENCE_CHECK = """
+ INSERT INTO goal_adherence_checks (study_id, check_id, title, time)
+ VALUES (:study_id, :check_id, :title, :time)
+ ON CONFLICT (study_id, check_id) DO UPDATE
+ SET title = EXCLUDED.title,
+ time = EXCLUDED.time;""";
private static final String IMPORT_ADHERENCE_CHECK = """
INSERT INTO goal_adherence_checks(study_id, check_id, title, time)
VALUES (:study_id, :check_id, :title, :time)
@@ -58,6 +61,7 @@ INSERT INTO goal_adherence_checks(study_id, check_id, title, time)
private static final String GET_ADHERENCE_CHECK_BY_ID = "SELECT * FROM goal_adherence_checks WHERE study_id = ? AND check_id = ?";
private static final String LIST_ADHERENCE_CHECKS = "SELECT * FROM goal_adherence_checks WHERE study_id = ? ORDER BY check_id";
private static final String DELETE_ADHERENCE_CHECK = "DELETE FROM goal_adherence_checks WHERE study_id = ? AND check_id = ?";
+ private static final String DELETE_ADHERENCE_CHECKS = "DELETE FROM goal_adherence_checks WHERE study_id = ?";
private static final String UPDATE_ADHERENCE_CHECK = "UPDATE goal_adherence_checks SET title = :title, time = :time WHERE study_id = :study_id AND check_id = :check_id";
private final JdbcTemplate template;
@@ -127,11 +131,9 @@ public void deleteTopic(Long studyId, String key) {
}
@Transactional
- public GoalAdherenceCheck insertCheck(GoalAdherenceCheck check) {
- KeyHolder keyHolder = new GeneratedKeyHolder();
- namedTemplate.update(INSERT_ADHERENCE_CHECK, toParams(check), keyHolder, new String[]{"check_id"});
- Integer checkId = keyHolder.getKey().intValue();
- return getCheckById(check.getStudyId(), checkId);
+ public GoalAdherenceCheck upsertCheck(GoalAdherenceCheck check) {
+ namedTemplate.update(UPSERT_ADHERENCE_CHECK, toParams(check));
+ return getCheckById(check.getStudyId(), check.getCheckId());
}
@Transactional
@@ -172,6 +174,10 @@ public GoalAdherenceCheck updateCheck(GoalAdherenceCheck check) {
return getCheckById(check.getStudyId(), check.getCheckId());
}
+ public void deleteChecks(Long studyId) {
+ template.update(DELETE_ADHERENCE_CHECKS, studyId);
+ }
+
public void deleteCheck(Long studyId, Integer checkId) {
template.update(DELETE_ADHERENCE_CHECK, studyId, checkId);
}
@@ -195,6 +201,7 @@ private MapSqlParameterSource toParams(GoalTopic topic) {
private MapSqlParameterSource toParams(GoalAdherenceCheck check) {
return new MapSqlParameterSource()
.addValue("study_id", check.getStudyId())
+ .addValue("check_id", check.getCheckId())
.addValue("title", check.getTitle())
.addValue("time", check.getTime());
}
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/TriggerJob.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/TriggerJob.java
index d345bd57..a7f283f2 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/TriggerJob.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/TriggerJob.java
@@ -25,25 +25,28 @@
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.BeanNotOfRequiredTypeException;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
public class TriggerJob implements Job {
private static final Logger LOGGER = LoggerFactory.getLogger(TriggerJob.class);
private final MoreSDK moreSDK;
- private final Map triggerFactories;
+ private final ApplicationContext applicationContext;
private final InterventionService interventionService;
private final ActionService actionService;
public TriggerJob(
MoreSDK moreSDK,
- Map triggerFactories,
InterventionService interventionService,
- ActionService actionService
- ) {
+ ActionService actionService,
+ ApplicationContext applicationContext) {
this.moreSDK = moreSDK;
- this.triggerFactories = triggerFactories;
+ this.applicationContext = applicationContext;
this.interventionService = interventionService;
this.actionService = actionService;
}
@@ -66,9 +69,8 @@ public void execute(JobExecutionContext context) throws JobExecutionException {
new SchedulingException(String.format("Cannot find trigger: sid:%s, iid:%s", studyId, interventionId))
);
- TriggerFactory factory = Optional.ofNullable(
- triggerFactories.get(trigger.getType())
- ).orElseThrow(() -> new SchedulingException("Cannot find triggerType " + trigger.getType()));
+ TriggerFactory factory = factory(trigger)
+ .orElseThrow(() -> new SchedulingException("Cannot find triggerType " + trigger.getType()));
MoreTriggerSDK sdk = moreSDK.scopedTriggerSDK(studyId, studyGroupId, interventionId);
Parameters parameters = new Parameters(Map.of("triggerTime", context.getFireTime()));
@@ -85,4 +87,13 @@ public void execute(JobExecutionContext context) throws JobExecutionException {
}
}
+
+ private Optional factory(Trigger trigger) {
+ try {
+ return Optional.of(applicationContext.getBean(trigger.getType(), TriggerFactory.class));
+ } catch (NoSuchBeanDefinitionException | BeanNotOfRequiredTypeException e){
+ return Optional.empty();
+ }
+ }
+
}
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java
index 2342d73b..22550ea3 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java
@@ -12,6 +12,7 @@
import io.redlink.more.studymanager.core.io.SimpleParticipant;
import io.redlink.more.studymanager.core.io.TimeRange;
import io.redlink.more.studymanager.core.measurement.MeasurementSet;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.core.sdk.MoreActionSDK;
import io.redlink.more.studymanager.core.sdk.MoreObservationSDK;
@@ -19,12 +20,15 @@
import io.redlink.more.studymanager.core.sdk.schedule.Schedule;
import io.redlink.more.studymanager.core.ui.DataViewData;
import io.redlink.more.studymanager.core.ui.ViewConfig;
+import io.redlink.more.studymanager.model.Goal;
import io.redlink.more.studymanager.model.Participant;
import io.redlink.more.studymanager.model.data.ElasticActionDataPoint;
import io.redlink.more.studymanager.model.data.ElasticDataPoint;
import io.redlink.more.studymanager.model.data.ElasticObservationDataPoint;
import io.redlink.more.studymanager.repository.NameValuePairRepository;
import io.redlink.more.studymanager.repository.ObservationRepository;
+import io.redlink.more.studymanager.repository.goals.GoalRepository;
+import io.redlink.more.studymanager.repository.goals.GoalTemplateRepository;
import io.redlink.more.studymanager.scheduling.SchedulingService;
import io.redlink.more.studymanager.scheduling.TriggerJob;
import io.redlink.more.studymanager.sdk.scoped.MoreActionSDKImpl;
@@ -32,6 +36,7 @@
import io.redlink.more.studymanager.sdk.scoped.MoreTriggerSDKImpl;
import io.redlink.more.studymanager.service.ElasticDataService;
import io.redlink.more.studymanager.service.ElasticService;
+import io.redlink.more.studymanager.service.GoalService;
import io.redlink.more.studymanager.service.ParticipantService;
import io.redlink.more.studymanager.service.PushNotificationService;
import org.slf4j.Logger;
@@ -67,12 +72,16 @@ public class MoreSDK {
private final ObservationRepository observationRepository;
+ private final GoalService goalService;
+
public MoreSDK(
NameValuePairRepository nvpairs,
SchedulingService schedulingService,
ParticipantService participantService,
ElasticService elasticService, ElasticDataService elasticDataService,
- PushNotificationService pushNotificationService, ObservationRepository observationRepository) {
+ PushNotificationService pushNotificationService,
+ ObservationRepository observationRepository,
+ GoalService goalService) {
this.nvpairs = nvpairs;
this.schedulingService = schedulingService;
this.participantService = participantService;
@@ -80,6 +89,7 @@ public MoreSDK(
this.elasticDataService = elasticDataService;
this.pushNotificationService = pushNotificationService;
this.observationRepository = observationRepository;
+ this.goalService = goalService;
}
public MoreActionSDK scopedActionSDK(Long studyId, Integer studyGroupId, int interventionId, int actionId, String actionType, int participantId) {
@@ -128,6 +138,8 @@ public Set listActiveParticipantsByQuery(long studyId, Integer studyGro
return participants;
}
+
+
public boolean sendPushNotification(long studyId, int participantId, String title, String message, Map data) {
LOGGER.debug("Sending message to participant (sid:{}, pid:{}): {} -- {}", studyId, participantId, title, message);
return pushNotificationService.sendPushNotification(studyId, participantId, title, message, data);
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreGoalSDKImpl.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreGoalSDKImpl.java
new file mode 100644
index 00000000..1a35d41a
--- /dev/null
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreGoalSDKImpl.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.sdk.scoped;
+
+import io.redlink.more.studymanager.core.sdk.MoreGoalSDK;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+import io.redlink.more.studymanager.sdk.MoreSDK;
+
+import java.io.Serializable;
+import java.util.Optional;
+
+public class MoreGoalSDKImpl extends MorePlatformSDKImpl implements MoreGoalSDK {
+
+ private final int goalId;
+
+ public MoreGoalSDKImpl(MoreSDK sdk, long studyId, Integer studyGroupId, int goalId) {
+ super(sdk, studyId, studyGroupId);
+ this.goalId = goalId;
+ }
+
+ @Override
+ public void setValue(String name, T value) {
+ sdk.nvpairs.setGoalValue(studyId, goalId, name, value);
+ }
+
+ @Override
+ public Optional getValue(String name, Class tClass) {
+ return sdk.nvpairs.getGoalValue(studyId, goalId, name, tClass);
+ }
+
+ @Override
+ public void removeValue(String name) {
+ sdk.nvpairs.removeGoalValue(studyId, goalId, name);
+ }
+
+ @Override
+ public int getGoalId() {
+ return goalId;
+ }
+
+}
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreGoalTemplateSDKImpl.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreGoalTemplateSDKImpl.java
new file mode 100644
index 00000000..b9d618f8
--- /dev/null
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreGoalTemplateSDKImpl.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.sdk.scoped;
+
+import io.redlink.more.studymanager.core.io.TimeRange;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.core.properties.ObservationProperties;
+import io.redlink.more.studymanager.core.sdk.MoreGoalSDK;
+import io.redlink.more.studymanager.core.sdk.MoreGoalTemplateSDK;
+import io.redlink.more.studymanager.core.ui.DataViewData;
+import io.redlink.more.studymanager.core.ui.ViewConfig;
+import io.redlink.more.studymanager.model.data.ElasticDataPoint;
+import io.redlink.more.studymanager.sdk.MoreSDK;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+
+public class MoreGoalTemplateSDKImpl extends MorePlatformSDKImpl implements MoreGoalTemplateSDK {
+
+ private final int templateId;
+
+ public MoreGoalTemplateSDKImpl(MoreSDK sdk, long studyId, Integer studyGroupId, int templateId) {
+ super(sdk, studyId, studyGroupId);
+ this.templateId = templateId;
+ }
+
+ @Override
+ public void setValue(String name, T value) {
+ sdk.nvpairs.setGoalTemplateValue(studyId, templateId, name, value);
+ }
+
+ @Override
+ public Optional getValue(String name, Class tClass) {
+ return sdk.nvpairs.getGoalTemplateValue(studyId, templateId, name, tClass);
+ }
+
+ @Override
+ public void removeValue(String name) {
+ sdk.nvpairs.removeGoalTemplateValue(studyId, templateId, name);
+ }
+
+ @Override
+ public int getTemplateId() {
+ return getTemplateId();
+ }
+}
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/GoalService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/GoalService.java
new file mode 100644
index 00000000..5182590f
--- /dev/null
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/GoalService.java
@@ -0,0 +1,196 @@
+package io.redlink.more.studymanager.service;
+
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.exception.BadRequestException;
+import io.redlink.more.studymanager.exception.NotFoundException;
+import io.redlink.more.studymanager.model.GoalAdherenceCheck;
+import io.redlink.more.studymanager.model.GoalTemplate;
+import io.redlink.more.studymanager.model.GoalTopic;
+import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.StudyGoalConfig;
+import io.redlink.more.studymanager.repository.goals.GoalConfigurationRepository;
+import io.redlink.more.studymanager.repository.goals.GoalRepository;
+import io.redlink.more.studymanager.repository.goals.GoalTemplateRepository;
+import org.springframework.beans.factory.BeanNotOfRequiredTypeException;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.context.ApplicationContext;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Service
+public class GoalService {
+
+ private final StudyStateService studyStateService;
+
+ private final GoalConfigurationRepository goalConfigRepo;
+ private final GoalTemplateRepository goalTemplateRepo;
+ private final GoalRepository goalRepo;
+ private final ApplicationContext applicationContext;
+
+ public GoalService(
+ StudyStateService studyStateService,
+ GoalConfigurationRepository goalConfigRepo,
+ GoalTemplateRepository goalTemplateRepo,
+ GoalRepository goalRepo,
+ ApplicationContext applicationContext) {
+ this.studyStateService = studyStateService;
+ this.goalConfigRepo = goalConfigRepo;
+ this.goalTemplateRepo = goalTemplateRepo;
+ this.goalRepo = goalRepo;
+ this.applicationContext = applicationContext;
+ }
+
+ public StudyGoalConfig getGoalConfig(long studyId){
+ return goalConfigRepo.getStudyGoalConfig(studyId);
+ }
+
+ @Transactional
+ public StudyGoalConfig setGoalConfig(StudyGoalConfig studyGoalConfig) {
+ studyStateService.assertStudyNotInState(studyGoalConfig.getStudyId(), Study.Status.CLOSED);
+ return goalConfigRepo.saveStudyGoalConfig(studyGoalConfig);
+ }
+
+
+ public Collection getGoalTopics(long studyId) {
+ return goalConfigRepo.listTopics(studyId);
+ }
+
+ public Collection getGoalAdherenceChecks(long studyId) {
+ return goalConfigRepo.listChecks(studyId);
+ }
+
+ /**
+ * Sets the adherence checks for the study to the parsed list
+ * @param studyId the study id
+ * @param goalAdherenceChecks the adherence chekcs. NOTE checks with a different studyId will be ignored!
+ * @return the updated adherence checks
+ */
+ @Transactional
+ public Collection setGoalAdherenceChecks(Long studyId, List goalAdherenceChecks) {
+ studyStateService.assertStudyNotInState(Objects.requireNonNull(studyId), Study.Status.CLOSED);
+ goalConfigRepo.deleteChecks(studyId);
+ return upsertAdherenceChecks(studyId, goalAdherenceChecks);
+ }
+
+ /**
+ * Upserts the parsed adherence checks for the parsed study. Adherence checks for other studies are ignored
+ * @param studyId the studyId
+ * @param checks the chekcs to insert or update
+ * @return the inserted and updated checks
+ */
+ @Transactional
+ public Collection upsertAdherenceChecks(Long studyId, List checks) {
+ studyStateService.assertStudyNotInState(Objects.requireNonNull(studyId), Study.Status.CLOSED);
+ return checks.stream()
+ .filter(goalAdherenceCheck -> studyId.equals(goalAdherenceCheck.getStudyId()))
+ .map(goalConfigRepo::upsertCheck)
+ .sorted(Comparator.comparing(GoalAdherenceCheck::getStudyId).thenComparing(GoalAdherenceCheck::getCheckId))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ @Transactional
+ public void deleteAdherenceChecks(long studyId) {
+ studyStateService.assertStudyNotInState(Objects.requireNonNull(studyId), Study.Status.CLOSED);
+ goalConfigRepo.deleteChecks(studyId);
+ }
+
+
+ public List listGoalTopics(long studyId) {
+ return goalConfigRepo.listTopics(studyId);
+ }
+
+ @Transactional
+ public GoalTopic setGoalTopic(GoalTopic goalTopic) {
+ studyStateService.assertStudyNotInState(Objects.requireNonNull(goalTopic.getStudyId()), Study.Status.CLOSED);
+ return goalConfigRepo.saveTopic(goalTopic);
+ }
+
+ @Transactional
+ public void deleteGoalTopic(Long studyId, String key) {
+ studyStateService.assertStudyNotInState(Objects.requireNonNull(studyId), Study.Status.CLOSED);
+ goalConfigRepo.deleteTopic(Objects.requireNonNull(studyId), Objects.requireNonNull(key));
+ }
+
+ public GoalTopic getGoalTopic(Long studyId, String key) {
+ return goalConfigRepo.getTopic(Objects.requireNonNull(studyId), Objects.requireNonNull(key));
+ }
+
+ public List listGoalTemplates(long studyId) {
+ return goalTemplateRepo.listGoalTemplates(studyId);
+ }
+
+ public GoalTemplate addGoalTemplate(GoalTemplate goalTemplate) {
+ studyStateService.assertStudyNotInState(Objects.requireNonNull(goalTemplate.getStudyId()), Study.Status.CLOSED);
+ return goalTemplateRepo.insert(validate(goalTemplate));
+ }
+
+ public GoalTemplate updateGoalTemplate(GoalTemplate goalTemplate) {
+ studyStateService.assertStudyNotInState(goalTemplate.getStudyId(), Study.Status.CLOSED);
+ return goalTemplateRepo.update(validate(goalTemplate));
+ }
+
+ public GoalTemplate importGoalTemplate(Long studyId, GoalTemplate goalTemplate) {
+ final GoalTemplateFactory factory = factory(goalTemplate);
+ if (factory == null) {
+ throw NotFoundException.ObservationFactory(goalTemplate.getType());
+ }
+ GoalTemplateProperties props = (GoalTemplateProperties) factory.preImport(goalTemplate.getProperties());
+ goalTemplate.setProperties(props);
+ return goalTemplateRepo.doImport(studyId, goalTemplate);
+ }
+
+ public void deleteGoalTemplate(Long studyId, Integer goalTemplateId) {
+ studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED);
+ goalTemplateRepo.deleteGoalTemplate(studyId, goalTemplateId);
+ }
+
+ public Optional getGoalTemplate(Long studyId, Integer goalTemplateId) {
+ try {
+ return Optional.ofNullable(goalTemplateRepo.getById(studyId, goalTemplateId));
+ } catch (BadRequestException e) {
+ return Optional.empty();
+ }
+ }
+
+
+ /**
+ * Ensures the goalTemplageFactory for the parsed goalTemplate
+ * @param template the goal template
+ * @return the goal template factory
+ * @throws NotFoundException if the {@link GoalTemplateFactory} for the parsed observation is not present
+ */
+ private GoalTemplateFactory factory(GoalTemplate template) {
+ return getGoalTemplateFactory(template)
+ .orElseThrow(() -> new NotFoundException(String.format("GoalTemplateFactory for GoalTemplate[study: %s, id:%s, type: %s]",
+ template.getStudyId(), template.getTemplateId(), template.getType())));
+ }
+
+ private GoalTemplate validate(GoalTemplate goalTemplate) {
+ try {
+ factory(goalTemplate).validate(goalTemplate.getProperties());
+ } catch (ConfigurationValidationException e) {
+ throw new BadRequestException(e.getMessage());
+ }
+ return goalTemplate;
+ }
+
+ public Optional getGoalTemplateFactory(GoalTemplate template) {
+ try {
+ return Optional.of(applicationContext.getBean(template.getType(), GoalTemplateFactory.class));
+ } catch (NoSuchBeanDefinitionException | BeanNotOfRequiredTypeException e){
+ return Optional.empty();
+ }
+ }
+
+
+}
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java
index 596ca9ab..09395ffe 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java
@@ -8,6 +8,7 @@
*/
package io.redlink.more.studymanager.service;
+import io.redlink.more.studymanager.core.factory.ObservationFactory;
import io.redlink.more.studymanager.event.StudyStateChangedEvent;
import io.redlink.more.studymanager.core.component.Component;
import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
@@ -31,6 +32,9 @@
import org.quartz.CronExpression;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.BeanNotOfRequiredTypeException;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@@ -42,8 +46,7 @@ public class InterventionService {
private final StudyStateService studyStateService;
private final InterventionRepository repository;
private final StudyRepository studyRepository;
- private final Map actionFactories;
- private final Map triggerFactories;
+ private final ApplicationContext applicationContext;
private final MoreSDK sdk;
private static final Logger LOGGER = LoggerFactory.getLogger(InterventionService.class);
@@ -52,13 +55,11 @@ public class InterventionService {
public InterventionService(StudyStateService studyStateService,
InterventionRepository repository, StudyRepository studyRepository,
MoreSDK sdk,
- Map triggerFactories,
- Map actionFactories) {
+ ApplicationContext applicationContext) {
this.studyStateService = studyStateService;
this.repository = repository;
this.studyRepository = studyRepository;
- this.actionFactories = actionFactories;
- this.triggerFactories = triggerFactories;
+ this.applicationContext = applicationContext;
this.sdk = sdk;
}
@@ -73,14 +74,16 @@ public Intervention importIntervention(Long studyId, Intervention intervention,
{
Trigger validated = validateTrigger(trigger);
- TriggerFactory factory = factory(validated);
+ TriggerFactory factory = factory(validated)
+ .orElseThrow(() -> NotFoundException.TriggerFactory(trigger.getType()));
validated.setProperties((TriggerProperties) factory.preImport(validated.getProperties()));
repository.importTrigger(studyId, imported.getInterventionId(), validateTrigger(validated));
}
actions.forEach(a -> {
Action validated = validateAction(a);
- ActionFactory factory = factory(validated);
+ ActionFactory factory = factory(validated)
+ .orElseThrow(() -> NotFoundException.ActionFactory(a.getType()));
validated.setProperties((ActionProperties) factory.preImport(validated.getProperties()));
repository.importAction(studyId, imported.getInterventionId(), validateAction(validated));
});
@@ -186,6 +189,7 @@ private List listTriggersFo
.map(intervention -> Optional.ofNullable(
getTriggerByIds(intervention.getStudyId(), intervention.getInterventionId()))
.map(trigger -> factory(trigger)
+ .orElseThrow(() -> NotFoundException.TriggerFactory(trigger.getType()))
.create(
sdk.scopedTriggerSDK(intervention.getStudyId(), intervention.getStudyGroupId(), intervention.getInterventionId()),
trigger.getProperties()
@@ -196,11 +200,10 @@ private List listTriggersFo
}
private Action validateAction(Action action) {
- if (!actionFactories.containsKey(action.getType())) {
- throw NotFoundException.ActionFactory(action.getType());
- }
try {
- factory(action).validate(action.getProperties());
+ factory(action)
+ .orElseThrow(() -> NotFoundException.ActionFactory(action.getType()))
+ .validate(action.getProperties());
} catch (ConfigurationValidationException e) {
throw new BadRequestException(e.getMessage());
}
@@ -208,11 +211,10 @@ private Action validateAction(Action action) {
}
private Trigger validateTrigger(Trigger trigger) {
- if (!triggerFactories.containsKey(trigger.getType())) {
- throw NotFoundException.TriggerFactory(trigger.getType());
- }
try {
- factory(trigger).validate(trigger.getProperties());
+ factory(trigger)
+ .orElseThrow(() -> NotFoundException.TriggerFactory(trigger.getType()))
+ .validate(trigger.getProperties());
if(trigger.getProperties().containsKey("cronSchedule")) {
try {
CronExpression.validateExpression(trigger.getProperties().get("cronSchedule").toString());
@@ -226,11 +228,19 @@ private Trigger validateTrigger(Trigger trigger) {
return trigger;
}
- private TriggerFactory factory(Trigger trigger) {
- return triggerFactories.get(trigger.getType());
+ private Optional factory(Trigger trigger) {
+ try {
+ return Optional.of(applicationContext.getBean(trigger.getType(), TriggerFactory.class));
+ } catch (NoSuchBeanDefinitionException | BeanNotOfRequiredTypeException e){
+ return Optional.empty();
+ }
}
- private ActionFactory factory(Action action) {
- return actionFactories.get(action.getType());
+ private Optional factory(Action action) {
+ try {
+ return Optional.of(applicationContext.getBean(action.getType(), ActionFactory.class));
+ } catch (NoSuchBeanDefinitionException | BeanNotOfRequiredTypeException e){
+ return Optional.empty();
+ }
}
}
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java
index 4c8fd059..e1e7de7c 100644
--- a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java
@@ -27,6 +27,8 @@
import io.redlink.more.studymanager.utils.RandomSchedulerUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.BeanNotOfRequiredTypeException;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.annotation.Lookup;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
@@ -48,7 +50,7 @@ public class ObservationService {
private final ObservationRepository repository;
private final MoreSDK sdk;
- ApplicationContext applicationContext;
+ private final ApplicationContext applicationContext;
public ObservationService(StudyStateService studyStateService,
ObservationRepository repository,
@@ -171,7 +173,11 @@ public List getParticipantObservationPrope
}
public Optional getObservationFactory(Observation observation) {
- return Optional.ofNullable(applicationContext.getBean(observation.getType(), ObservationFactory.class));
+ try {
+ return Optional.of(applicationContext.getBean(observation.getType(), ObservationFactory.class));
+ } catch (NoSuchBeanDefinitionException | BeanNotOfRequiredTypeException e){
+ return Optional.empty();
+ }
}
/**
diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/utils/SlugUtils.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/utils/SlugUtils.java
new file mode 100644
index 00000000..a58f983f
--- /dev/null
+++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/utils/SlugUtils.java
@@ -0,0 +1,62 @@
+package io.redlink.more.studymanager.utils;
+
+import java.text.Normalizer;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+public class SlugUtils {
+
+ private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]");
+ private static final Pattern WHITESPACE = Pattern.compile("[\\s]+");
+ private static final Pattern MULTIPLE_HYPHENS = Pattern.compile("-+");
+
+ /** Regex for a valid slug: lowercase letters, numbers, and single hyphens only */
+ private static final Pattern VALID_SLUG = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$");
+ /**
+ * Converts a string into a URL-friendly slug.
+ * Example: "Hello World! How are you? Café" → "hello-world-how-are-you-cafe"
+ */
+ public static String toSlug(String input) {
+ if (input == null || input.trim().isEmpty()) {
+ return "";
+ }
+
+ String slug = input.trim();
+
+ // Normalize diacritics (é → e, ç → c, etc.)
+ slug = Normalizer.normalize(slug, Normalizer.Form.NFD);
+ slug = slug.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
+
+ // Convert to lowercase
+ slug = slug.toLowerCase(Locale.ENGLISH);
+
+ // Replace whitespace with hyphens
+ slug = WHITESPACE.matcher(slug).replaceAll("-");
+
+ // Remove all non-word characters (except hyphens)
+ slug = NON_LATIN.matcher(slug).replaceAll("");
+
+ // Collapse multiple hyphens
+ slug = MULTIPLE_HYPHENS.matcher(slug).replaceAll("-");
+
+ // Remove leading/trailing hyphens
+ slug = slug.replaceAll("^-|-$", "");
+
+ return slug;
+ }
+
+ /**
+ * Checks if a string is a valid slug.
+ * A valid slug must:
+ * - Contain only lowercase letters (a-z), digits (0-9), and hyphens (-)
+ * - Not start or end with a hyphen
+ * - Not contain consecutive hyphens
+ * - Not be empty
+ */
+ public static boolean isSlug(String str) {
+ if (str == null || str.isEmpty()) {
+ return false;
+ }
+ return VALID_SLUG.matcher(str).matches();
+ }
+}
diff --git a/studymanager-services/src/main/resources/db/migration/V1_24_0__goals.sql b/studymanager-services/src/main/resources/db/migration/V1_24_0__goals.sql
index fc132ca9..7d9c022e 100644
--- a/studymanager-services/src/main/resources/db/migration/V1_24_0__goals.sql
+++ b/studymanager-services/src/main/resources/db/migration/V1_24_0__goals.sql
@@ -107,3 +107,25 @@ CREATE TABLE goal (
FOREIGN KEY (study_id, participant_id) REFERENCES participants(study_id, participant_id) ON DELETE CASCADE,
FOREIGN KEY (study_id, template_id) REFERENCES goal_templates(study_id, template_id) ON DELETE CASCADE
);
+
+
+CREATE TABLE IF NOT EXISTS nvpairs_goaltemplates (
+ study_id BIGINT NOT NULL,
+ template_id INT,
+ name VARCHAR,
+ value bytea NOT NULL,
+
+ PRIMARY KEY (study_id, template_id, name),
+ FOREIGN KEY (study_id, template_id) REFERENCES goal_templates(study_id, template_id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS nvpairs_goals (
+ study_id BIGINT NOT NULL,
+ goal_id INT,
+ name VARCHAR,
+ value bytea NOT NULL,
+
+ PRIMARY KEY (study_id, goal_id, name),
+ FOREIGN KEY (study_id, goal_id) REFERENCES goal(study_id, goal_id) ON DELETE CASCADE
+);
+
diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepositoryTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepositoryTest.java
index 08618a84..c78f43dd 100644
--- a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepositoryTest.java
+++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalConfigurationRepositoryTest.java
@@ -148,13 +148,25 @@ void testGoalAdherenceCheckCRUD() {
Long studyY = studyRepository.insert(new Study().setContact(new Contact().setPerson("Y"))).getStudyId();
// Create multiple checks per study
- GoalAdherenceCheck cX1 = goalConfigurationRepository.insertCheck(
- new GoalAdherenceCheck().setStudyId(studyX).setTitle("Morning").setTime(LocalTime.of(8, 0)));
- GoalAdherenceCheck cX2 = goalConfigurationRepository.insertCheck(
- new GoalAdherenceCheck().setStudyId(studyX).setTitle("Evening").setTime(LocalTime.of(20, 30)));
-
- GoalAdherenceCheck cY1 = goalConfigurationRepository.insertCheck(
- new GoalAdherenceCheck().setStudyId(studyY).setTitle("Noon").setTime(LocalTime.of(12, 0)));
+ GoalAdherenceCheck cX1 = goalConfigurationRepository.upsertCheck(
+ new GoalAdherenceCheck()
+ .setStudyId(studyX)
+ .setCheckId(0)
+ .setTitle("Morning")
+ .setTime(LocalTime.of(8, 0)));
+ GoalAdherenceCheck cX2 = goalConfigurationRepository.upsertCheck(
+ new GoalAdherenceCheck()
+ .setStudyId(studyX)
+ .setCheckId(4)
+ .setTitle("Evening")
+ .setTime(LocalTime.of(20, 30)));
+
+ GoalAdherenceCheck cY1 = goalConfigurationRepository.upsertCheck(
+ new GoalAdherenceCheck()
+ .setStudyId(studyY)
+ .setCheckId(2)
+ .setTitle("Noon")
+ .setTime(LocalTime.of(12, 0)));
// List per study
assertThat(goalConfigurationRepository.listChecks(studyX))
@@ -162,6 +174,18 @@ void testGoalAdherenceCheckCRUD() {
.extracting(GoalAdherenceCheck::getTitle)
.containsExactlyInAnyOrder("Morning", "Evening");
+ //update check
+ cX2.setTime(LocalTime.of(20, 15));
+ GoalAdherenceCheck cX2Updated = goalConfigurationRepository.upsertCheck(cX2);
+ assertThat(cX2Updated.getTime()).isEqualTo(LocalTime.of(20, 15));
+
+ assertThat(goalConfigurationRepository.listChecks(studyX))
+ .hasSize(2)
+ .filteredOn(ac -> "Evening".equals(ac.getTitle()))
+ .extracting(GoalAdherenceCheck::getTime)
+ .containsOnly(cX2Updated.getTime());
+
+
assertThat(goalConfigurationRepository.listChecks(studyY))
.hasSize(1)
.extracting(GoalAdherenceCheck::getTitle)
diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalTemplateRepositoryTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalTemplateRepositoryTest.java
index 4baefdc9..963731ff 100644
--- a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalTemplateRepositoryTest.java
+++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/goals/GoalTemplateRepositoryTest.java
@@ -71,8 +71,11 @@ public void testInsertListUpdateDeleteAndListForGroupSemantics() {
goalConfigurationRepository.saveTopic(new GoalTopic().setStudyId(studyId).setKey("health").setTitle("Health"));
goalConfigurationRepository.saveTopic(new GoalTopic().setStudyId(studyId).setKey("lifestyle").setTitle("Lifestyle"));
- GoalAdherenceCheck morningCheck = goalConfigurationRepository.insertCheck(
- new GoalAdherenceCheck().setStudyId(studyId).setTitle("Morning").setTime(LocalTime.of(8, 0))
+ GoalAdherenceCheck morningCheck = goalConfigurationRepository.upsertCheck(
+ new GoalAdherenceCheck().setStudyId(studyId)
+ //NOTE: CheckId is expected to be set on the ordinal of an enum, title to the name
+ .setCheckId(1).setTitle("Morning")
+ .setTime(LocalTime.of(8, 0))
);
// === Create GoalTemplates with all combinations (exactly like the Observation test) ===
diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/sdk/MoreSDKTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/sdk/MoreSDKTest.java
index b8274f55..a194f75d 100644
--- a/studymanager-services/src/test/java/io/redlink/more/studymanager/sdk/MoreSDKTest.java
+++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/sdk/MoreSDKTest.java
@@ -78,6 +78,9 @@ class MoreSDKTest {
@MockitoBean
ParticipantService participantService;
+ @MockitoBean
+ GoalService goalService;
+
@MockitoBean
ElasticService elasticService;
diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java
index 3aff8a2c..51fce5ae 100644
--- a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java
+++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java
@@ -10,7 +10,6 @@
import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
import io.redlink.more.studymanager.core.validation.ConfigurationValidationReport;
-import io.redlink.more.studymanager.event.StudyStateChangedEvent;
import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.exception.NotFoundException;
import io.redlink.more.studymanager.core.factory.TriggerFactory;
@@ -28,21 +27,20 @@
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
-import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.context.ApplicationContext;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InterventionServiceTest {
@Mock
- Map triggerFactories;
+ ApplicationContext applicationContext;
@Mock
StudyStateService studyStateService;
@Mock
@@ -60,6 +58,7 @@ class InterventionServiceTest {
@Test
void testNotFoundValidation() {
+ when(applicationContext.getBean("my-trigger", TriggerFactory.class)).thenThrow(new NoSuchBeanDefinitionException("my-trigger"));
NotFoundException notFoundException = Assertions.assertThrows(NotFoundException.class, () ->
interventionService.updateTrigger(1L, 1, new Trigger().setType("my-trigger"))
);
@@ -70,8 +69,7 @@ void testNotFoundValidation() {
void testBadRequestValidation() {
TriggerFactory factory = mock(TriggerFactory.class);
when(factory.validate(any())).thenThrow(new ConfigurationValidationException(ConfigurationValidationReport.init().error("My error")));
- when(triggerFactories.get("my-trigger")).thenReturn(factory);
- when(triggerFactories.containsKey("my-trigger")).thenReturn(true);
+ when(applicationContext.getBean("my-trigger", TriggerFactory.class)).thenReturn(factory);
Assertions.assertThrows(BadRequestException.class, () ->
interventionService.updateTrigger(1L, 1, new Trigger().setType("my-trigger"))
diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java
index 844bf9a5..681ab64d 100644
--- a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java
+++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java
@@ -34,6 +34,7 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.ApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
@@ -61,10 +62,11 @@ class ObservationServiceTest {
@Test
void testValidation() {
+ when(applicationContext.getBean("not-existing-observation", ObservationFactory.class)).thenThrow(NoSuchBeanDefinitionException.class);
NotFoundException notFoundException = Assertions.assertThrows(NotFoundException.class, () ->
- observationService.addObservation(new Observation().setStudyId(1L).setObservationId(1).setType("my-observation"))
+ observationService.addObservation(new Observation().setStudyId(1L).setObservationId(1).setType("not-existing-observation"))
);
- Assertions.assertEquals("ObservationFactory for Observation[study: 1, id:1, type: my-observation] cannot be found", notFoundException.getMessage());
+ Assertions.assertEquals("ObservationFactory for Observation[study: 1, id:1, type: not-existing-observation] cannot be found", notFoundException.getMessage());
ObservationFactory factory = mock(ObservationFactory.class);
when(factory.validate(any())).thenThrow(new ConfigurationValidationException(ConfigurationValidationReport.init().error("My error")));
@@ -152,7 +154,7 @@ void testGetObservationFactory_optional() {
java.util.Optional present = observationService.getObservationFactory(obs);
org.assertj.core.api.Assertions.assertThat(present).containsSame(factory);
- org.mockito.Mockito.when(applicationContext.getBean("x", ObservationFactory.class)).thenReturn(null);
+ org.mockito.Mockito.when(applicationContext.getBean("x", ObservationFactory.class)).thenThrow(NoSuchBeanDefinitionException.class);
java.util.Optional empty = observationService.getObservationFactory(obs);
org.assertj.core.api.Assertions.assertThat(empty).isEmpty();
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ComponentApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ComponentApiV1Controller.java
index 41b33df6..c04af973 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ComponentApiV1Controller.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ComponentApiV1Controller.java
@@ -18,6 +18,7 @@
import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
import io.redlink.more.studymanager.core.factory.ActionFactory;
import io.redlink.more.studymanager.core.factory.ComponentFactory;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
import io.redlink.more.studymanager.core.factory.ObservationFactory;
import io.redlink.more.studymanager.core.factory.TriggerFactory;
import io.redlink.more.studymanager.core.io.Visibility;
@@ -31,6 +32,9 @@
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.context.ApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -41,29 +45,30 @@
@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE)
public class ComponentApiV1Controller implements ComponentsApi {
- private final Map observationFactories;
- private final Map triggertFactories;
- private final Map actionFactories;
+ private final ObjectProvider observationFactoryProvider;
+ private final ObjectProvider triggerFactoryProvider;
+ private final ObjectProvider actionFactoryProvider;
+ private final ObjectProvider goalTemplateFactoryProvider;
private final OAuth2AuthenticationService authService;
public ComponentApiV1Controller(
- Map observationFactories,
- Map triggertFactories,
- Map actionFactories,
+ ApplicationContext applicationContext,
OAuth2AuthenticationService authService
) {
- this.observationFactories = observationFactories;
- this.triggertFactories = triggertFactories;
- this.actionFactories = actionFactories;
+ this.observationFactoryProvider = applicationContext.getBeanProvider(ObservationFactory.class);
+ this.triggerFactoryProvider = applicationContext.getBeanProvider(TriggerFactory.class);
+ this.actionFactoryProvider = applicationContext.getBeanProvider(ActionFactory.class);
+ this.goalTemplateFactoryProvider = applicationContext.getBeanProvider(GoalTemplateFactory.class);
this.authService = authService;
}
@Override
public ResponseEntity> listComponents(String componentType) {
return switch (componentType) {
- case "observation" -> ResponseEntity.ok(observationFactories.values().stream().map(this::toComponentDTO).toList());
- case "trigger" -> ResponseEntity.ok(triggertFactories.values().stream().map(this::toComponentDTO).toList());
- case "action" -> ResponseEntity.ok(actionFactories.values().stream().map(this::toComponentDTO).toList());
+ case "observation" -> ResponseEntity.ok(observationFactoryProvider.stream().map(this::toComponentDTO).toList());
+ case "trigger" -> ResponseEntity.ok(triggerFactoryProvider.stream().map(this::toComponentDTO).toList());
+ case "action" -> ResponseEntity.ok(actionFactoryProvider.stream().map(this::toComponentDTO).toList());
+ case "goalTemplate" -> ResponseEntity.ok(goalTemplateFactoryProvider.stream().map(this::toComponentDTO).toList());
default -> ResponseEntity.notFound().build();
};
}
@@ -107,15 +112,19 @@ public ResponseEntity getWebComponentScript(String componentType, String
private Optional getComponentFactory(String componentType, String componentId) {
return switch (componentType) {
- case "observation" -> getComponentFactory(observationFactories, componentId);
- case "trigger" -> getComponentFactory(triggertFactories, componentId);
- case "action" -> getComponentFactory(actionFactories, componentId);
+ case "observation" -> getComponentFactory(observationFactoryProvider, componentId);
+ case "trigger" -> getComponentFactory(triggerFactoryProvider, componentId);
+ case "action" -> getComponentFactory(actionFactoryProvider, componentId);
+ case "goalTemplate" -> getComponentFactory(goalTemplateFactoryProvider, componentId);
default -> Optional.empty();
};
}
- private Optional getComponentFactory(Map factories, String componentId) {
- return Optional.ofNullable(factories.get(componentId));
+ private Optional getComponentFactory(ObjectProvider extends ComponentFactory> factories, String componentId) {
+ return factories.stream()
+ .filter(f -> f.getId().equals(componentId))
+ .map(ComponentFactory.class::cast)
+ .findFirst();
}
private ResponseEntity getWebComponentScript(ComponentFactory factory, String componentId) {
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/GoalsApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/GoalsApiV1Controller.java
new file mode 100644
index 00000000..d4ef7c00
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/GoalsApiV1Controller.java
@@ -0,0 +1,188 @@
+package io.redlink.more.studymanager.controller.studymanager;
+
+import io.redlink.more.studymanager.api.v1.model.GoalTemplateDTO;
+import io.redlink.more.studymanager.api.v1.model.GoalTopicDTO;
+import io.redlink.more.studymanager.api.v1.model.StudyGoalConfigDTO;
+import io.redlink.more.studymanager.api.v1.model.StudyGoalConfigDataDTO;
+import io.redlink.more.studymanager.api.v1.webservices.GoalsApi;
+import io.redlink.more.studymanager.audit.Audited;
+import io.redlink.more.studymanager.exception.BadRequestException;
+import io.redlink.more.studymanager.exception.NotFoundException;
+import io.redlink.more.studymanager.model.AuthenticatedUser;
+import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.StudyGoalConfig;
+import io.redlink.more.studymanager.model.transformer.GoalV1Transformer;
+import io.redlink.more.studymanager.service.OAuth2AuthenticationService;
+import io.redlink.more.studymanager.service.StudyService;
+import io.redlink.more.studymanager.service.GoalService;
+import io.redlink.more.studymanager.utils.SlugUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE)
+public class GoalsApiV1Controller implements GoalsApi {
+
+
+ private final StudyService studyService;
+ private final GoalService goalService;
+ private final OAuth2AuthenticationService authService;
+
+ public GoalsApiV1Controller(
+ StudyService studyService,
+ GoalService goalService,
+ OAuth2AuthenticationService authService) {
+ this.studyService = studyService;
+ this.goalService = goalService;
+ this.authService = authService;
+ }
+
+ /* ---
+ * Goal Study Config API
+ * ---
+ */
+
+ @Override
+ @Audited
+ public ResponseEntity getGoalConfig(Long studyId) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ var config = goalService.getGoalConfig(studyId);
+ if(config == null) {
+ //no custom goal config set fpr this stury - use the default one (for now empty)
+ config = new StudyGoalConfig();
+ }
+ return ResponseEntity.ok(GoalV1Transformer.toStudyGoalConfigDataDTO_V1(
+ config,
+ goalService.getGoalTopics(studyId),
+ goalService.getGoalAdherenceChecks(studyId)));
+ }
+
+ @Override
+ @Audited
+ public ResponseEntity setGoalConfig(Long studyId, StudyGoalConfigDTO studyGoalConfigDTO) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ var config = GoalV1Transformer.toStudyGoalConfig(studyGoalConfigDTO, studyId);
+ var checks = GoalV1Transformer.toGoalAdherenceChecks(studyGoalConfigDTO, studyId);
+ var updatedConfig = goalService.setGoalConfig(config);
+ var updatedChecks = goalService.setGoalAdherenceChecks(studyId, checks);
+ if(updatedConfig == null) {
+ return ResponseEntity.notFound().build();
+ } else {
+ return ResponseEntity.ok(GoalV1Transformer.toStudyGoalConfigDataDTO_V1(
+ updatedConfig,
+ goalService.getGoalTopics(studyId),
+ updatedChecks));
+ }
+ }
+
+ /* ---
+ * Goal Topic API
+ * ---
+ */
+
+ @Override
+ @Audited
+ public ResponseEntity createGoalTopic(Long studyId, GoalTopicDTO goalTopicDTO) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ if(goalTopicDTO.getKey() == null) {
+ goalTopicDTO.setKey(SlugUtils.toSlug(goalTopicDTO.getTitle()));
+ }
+ if(!SlugUtils.isSlug(goalTopicDTO.getKey())) {
+ throw new BadRequestException(String.format("The key of the parsed Topic is not a valid Slug (key: %s, suggested: %s)",
+ goalTopicDTO.getKey(), SlugUtils.toSlug(goalTopicDTO.getKey())));
+ }
+ if(goalService.getGoalTopic(studyId, goalTopicDTO.getKey()) != null){
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ return responseGoalTopic(studyId, goalTopicDTO);
+ }
+
+ @Override
+ @Audited
+ public ResponseEntity updateGoalTopic(Long studyId, String key, GoalTopicDTO goalTopicDTO) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ return responseGoalTopic(studyId, goalTopicDTO);
+ }
+
+ @Override
+ @Audited
+ public ResponseEntity deleteGoalTopic(Long studyId, String key) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ goalService.deleteGoalTopic(studyId, key);
+ return ResponseEntity.noContent().build();
+ }
+
+ /* ---
+ * Goal Template API
+ * ---
+ */
+
+ @Override
+ @Audited
+ public ResponseEntity> listGoalTemplates(Long studyId) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ return ResponseEntity.ok(goalService.listGoalTemplates(studyId).stream()
+ .map(GoalV1Transformer::toGoalTemplateDTO_V1)
+ .collect(Collectors.toList()));
+ }
+
+ @Override
+ @Audited
+ public ResponseEntity addGoalTemplate(Long studyId, GoalTemplateDTO goalTemplateDTO) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ return ResponseEntity.ok(
+ GoalV1Transformer.toGoalTemplateDTO_V1(
+ goalService.addGoalTemplate(GoalV1Transformer.toGoalTemplate(goalTemplateDTO, studyId))));
+ }
+
+ @Override
+ @Audited
+ public ResponseEntity updateGoalTemplate(Long studyId, Integer templateId, GoalTemplateDTO goalTemplateDTO) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ return ResponseEntity.ok(
+ GoalV1Transformer.toGoalTemplateDTO_V1(
+ goalService.updateGoalTemplate(
+ GoalV1Transformer.toGoalTemplate(goalTemplateDTO, studyId, templateId))));
+ }
+
+ @Override
+ @Audited
+ public ResponseEntity deleteGoalTemplate(Long studyId, Integer templateId) {
+ final var currentUser = authService.getCurrentUser();
+ validateStudyForUser(studyId, currentUser);
+ goalService.deleteGoalTemplate(studyId, templateId);
+ return ResponseEntity.noContent().build();
+ }
+
+ /*
+ * Utility methods
+ */
+ private ResponseEntity responseGoalTopic(Long studyId, GoalTopicDTO goalTopicDTO) {
+ var topic = goalService.setGoalTopic(GoalV1Transformer.toGoalTopic(goalTopicDTO, studyId));
+ if(topic == null) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok(GoalV1Transformer.toGoalTopicDTO_V1(topic));
+ }
+
+ private void validateStudyForUser(Long studyId, AuthenticatedUser currentUser) {
+ Study study = studyService.getStudy(studyId, currentUser)
+ .orElseThrow(() -> new NotFoundException(String.format("Study %s not found", studyId)));
+ }
+
+
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/LimeSurveySidecarController.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/LimeSurveySidecarController.java
index 84db3d34..6e8d3755 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/LimeSurveySidecarController.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/LimeSurveySidecarController.java
@@ -14,6 +14,7 @@
import io.redlink.more.studymanager.service.ObservationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@@ -25,14 +26,14 @@
public class LimeSurveySidecarController {
private static final Logger LOG = LoggerFactory.getLogger(LimeSurveySidecarController.class);
- private final LimeSurveyObservationFactory factory;
+ private final ApplicationContext applicationContext;
private final ObservationService observationService;
private final MoreSDK sdk;
- public LimeSurveySidecarController(LimeSurveyObservationFactory factory, ObservationService observationService, MoreSDK sdk) {
- this.factory = factory;
+ public LimeSurveySidecarController(ObservationService observationService, MoreSDK sdk, ApplicationContext applicationContext) {
+ this.applicationContext = applicationContext;
this.observationService = observationService;
this.sdk = sdk;
}
@@ -57,11 +58,16 @@ public ResponseEntity getLimeSurveyEndPageAndWriteResult(
) {
LOG.info("Requesting LimeSurveyEndPage for studyID {}, observationID {}, token {}, surveyID: {}, saveId: {}", studyId, observationId, token, surveyid, savedid);
return observationService.getObservation(studyId, observationId)
- .map(o -> factory.create(sdk.scopedObservationSDK(o.getStudyId(), o.getStudyGroupId(), o.getObservationId()), o.getProperties()))
+ .map(o -> lookupLimeSurveyObservationFactory().create(sdk.scopedObservationSDK(o.getStudyId(), o.getStudyGroupId(), o.getObservationId()), o.getProperties()))
.map(obs -> obs.writeDataPoints(token, surveyid, savedid))
.map(r -> r ? true : null)
.map(r -> ResponseEntity.ok("Survey submitted
"))
.orElse(ResponseEntity.status(401).build());
}
+ private LimeSurveyObservationFactory lookupLimeSurveyObservationFactory() {
+ return applicationContext.getBean(LimeSurveyObservationFactory.class);
+ }
+
+
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/GoalV1Transformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/GoalV1Transformer.java
new file mode 100644
index 00000000..bd03d93b
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/GoalV1Transformer.java
@@ -0,0 +1,253 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.model.transformer;
+
+import io.redlink.more.studymanager.api.v1.model.*;
+import io.redlink.more.studymanager.core.properties.GoalTemplateProperties;
+import io.redlink.more.studymanager.model.GoalAdherenceCheck;
+import io.redlink.more.studymanager.model.GoalTemplate;
+import io.redlink.more.studymanager.model.GoalTopic;
+import io.redlink.more.studymanager.model.StudyGoalConfig;
+
+import java.time.*;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public final class GoalV1Transformer {
+
+ private GoalV1Transformer() {}
+
+// ========================== MODEL → DTO ==========================
+
+ public static StudyGoalConfigDataDTO toStudyGoalConfigDataDTO_V1(
+ StudyGoalConfig config,
+ Collection topics,
+ Collection checks) {
+
+ final StudyGoalConfigConsentsDTO consents = toStudyGoalConfigConsentsDTO_V1(config);
+
+ final List schedule = checks.stream()
+ .map(GoalV1Transformer::toStudyGoalConfigScheduleInnerDTO_V1)
+ .toList();
+
+ final List topicDtos = topics.stream()
+ .map(GoalV1Transformer::toGoalTopicDTO_V1)
+ .toList();
+
+ return new StudyGoalConfigDataDTO()
+ .consents(consents)
+ .schedule(schedule)
+ .topics(topicDtos);
+ }
+
+ public static StudyGoalConfigConsentsDTO toStudyGoalConfigConsentsDTO_V1(StudyGoalConfig config) {
+ return new StudyGoalConfigConsentsDTO()
+ .commitment(config.getCommitment())
+ .achievability(config.getAchievability())
+ .understandable(config.getUnderstandability());
+ }
+
+ public static StudyGoalConfigScheduleInnerDTO toStudyGoalConfigScheduleInnerDTO_V1(GoalAdherenceCheck check) {
+ return new StudyGoalConfigScheduleInnerDTO()
+ .key(mapTitleToScheduleEnum(check.getTitle()))
+ .time(check.getTime()); // LocalTime → LocalTime (no conversion needed)
+ }
+
+ public static GoalTopicDTO toGoalTopicDTO_V1(GoalTopic topic) {
+ return new GoalTopicDTO()
+ .key(topic.getKey())
+ .title(topic.getTitle())
+ .description(topic.getDescription());
+ }
+
+ public static GoalTemplateDTO toGoalTemplateDTO_V1(GoalTemplate template) {
+ if (template == null) return null;
+
+ GoalTemplateCategoriesDTO categories = new GoalTemplateCategoriesDTO()
+ .kind(mapKindToEnum(template.getKind()))
+ .topics(template.getTopicKeys() != null ? template.getTopicKeys().stream().toList() : List.of());
+
+ List adherenceChecks = template.getAdherenceCheckIds() != null
+ ? template.getAdherenceCheckIds().stream()
+ .map(GoalV1Transformer::mapOrdinalToAdherenceEnum) // if needed, otherwise you may store the enum value directly
+ .filter(Objects::nonNull)
+ .toList()
+ : List.of();
+
+ return new GoalTemplateDTO()
+ .studyId(template.getStudyId())
+ .templateId(template.getTemplateId())
+ .studyGroupId(template.getStudyGroupId())
+ .observationGroupIds(template.getObservationGroupIds() != null ? template.getObservationGroupIds() : Set.of())
+ .title(template.getTitle())
+ .participantTitle(template.getParticipantTitle())
+ .participantInfo(template.getParticipantInfo())
+ .type(template.getType())
+ .categories(categories)
+ .adherenceChecks(adherenceChecks)
+ .properties(template.getProperties() != null ? template.getProperties() : Map.of())
+ .created(template.getCreated())
+ .modified(template.getModified());
+ }
+
+ // ========================== DTO → MODEL ==========================
+
+ public static StudyGoalConfig toStudyGoalConfig(StudyGoalConfigDTO dto, Long studyId) {
+ if (dto == null) return null;
+
+ return new StudyGoalConfig()
+ .setStudyId(studyId)
+ .setCommitment(dto.getConsents() != null ? dto.getConsents().getCommitment() : null)
+ .setAchievability(dto.getConsents() != null ? dto.getConsents().getAchievability() : null)
+ .setUnderstandability(dto.getConsents() != null ? dto.getConsents().getUnderstandable() : null);
+ }
+
+ public static List toGoalTopics(StudyGoalConfigDataDTO dto, Long studyId) {
+ if (dto == null || dto.getTopics() == null) return List.of();
+
+ return dto.getTopics().stream()
+ .filter(Objects::nonNull)
+ .map(t -> toGoalTopic(t, studyId))
+ .toList();
+ }
+
+ public static GoalTopic toGoalTopic(GoalTopicDTO dto, Long studyId) {
+ if (dto == null) return null;
+
+ return new GoalTopic()
+ .setStudyId(studyId)
+ .setKey(dto.getKey())
+ .setTitle(dto.getTitle())
+ .setDescription(dto.getDescription())
+ .setCreated(null) // set by service layer
+ .setModified(null);
+ }
+
+ public static List toGoalAdherenceChecks(StudyGoalConfigDTO dto, Long studyId) {
+ if (dto == null || dto.getSchedule() == null) return List.of();
+
+ return dto.getSchedule().stream()
+ .filter(Objects::nonNull)
+ .map(s -> toGoalAdherenceCheck(s, studyId))
+ .toList();
+ }
+
+ public static GoalAdherenceCheck toGoalAdherenceCheck(StudyGoalConfigScheduleInnerDTO dto, Long studyId) {
+ if (dto == null) return null;
+
+ return new GoalAdherenceCheck()
+ .setStudyId(studyId)
+ .setCheckId(dto.getKey() != null ? dto.getKey().ordinal() : null)
+ .setTitle(mapScheduleEnumToTitle(dto.getKey()))
+ .setTime(dto.getTime()); // LocalTime → LocalTime (direct)
+ }
+
+ public static GoalTemplate toGoalTemplate(GoalTemplateDTO dto, Long studyId) {
+ return toGoalTemplate(dto, studyId, dto.getTemplateId());
+ }
+
+ public static GoalTemplate toGoalTemplate(GoalTemplateDTO dto, Long studyId, Integer goalTemplateId) {
+ if (dto == null) return null;
+
+ GoalTemplate template = new GoalTemplate()
+ .setStudyId(studyId)
+ .setTemplateId(goalTemplateId)
+ .setStudyGroupId(dto.getStudyGroupId())
+ .setTitle(dto.getTitle())
+ .setParticipantTitle(dto.getParticipantTitle())
+ .setParticipantInfo(dto.getParticipantInfo())
+ .setType(dto.getType())
+ .setKind(mapKindEnumToString(dto.getCategories() != null ? dto.getCategories().getKind() : null))
+ .setCreated(dto.getCreated())
+ .setModified(dto.getModified());
+
+ // observationGroupIds
+ if (dto.getObservationGroupIds() != null) {
+ template.setObservationGroupIds(new HashSet<>(dto.getObservationGroupIds()));
+ }
+
+ // topicKeys
+ if (dto.getCategories() != null && dto.getCategories().getTopics() != null) {
+ template.setTopicKeys(new HashSet<>(dto.getCategories().getTopics()));
+ }
+
+ // Adherence Checks: Enum → Integer (ordinal)
+ if (dto.getAdherenceChecks() != null) {
+ Set checkIds = dto.getAdherenceChecks().stream()
+ .map(GoalV1Transformer::mapAdherenceEnumToOrdinal)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ template.setAdherenceCheckIds(checkIds);
+ }
+
+ // properties
+ if (dto.getProperties() != null && !dto.getProperties().isEmpty()) {
+ GoalTemplateProperties props = new GoalTemplateProperties(dto.getProperties());
+ template.setProperties(props);
+ }
+
+ return template;
+ }
+ // ========================== HELPER METHODS ==========================
+
+ private static AdherenceCheckScheduleEnumDTO mapTitleToScheduleEnum(String title) {
+ if (title == null) return null;
+ String normalized = title.trim().toLowerCase();
+ try {
+ return AdherenceCheckScheduleEnumDTO.fromValue(normalized);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private static String mapScheduleEnumToTitle(AdherenceCheckScheduleEnumDTO enumValue) {
+ return enumValue != null ? enumValue.getValue() : null;
+ }
+ private static String mapKindEnumToString(GoalTemplateCategoriesDTO.KindEnum kindEnum) {
+ return kindEnum != null ? kindEnum.getValue() : null;
+ }
+
+ /**
+ * Maps internal ordinal (stored in adherenceCheckIds) to DTO enum.
+ */
+ private static AdherenceCheckScheduleEnumDTO mapOrdinalToAdherenceEnum(Integer ordinal) {
+ if (ordinal == null) return null;
+ AdherenceCheckScheduleEnumDTO[] values = AdherenceCheckScheduleEnumDTO.values();
+ if (ordinal >= 0 && ordinal < values.length) {
+ return values[ordinal];
+ }
+ return null;
+ }
+
+ /**
+ * Maps DTO enum to internal ordinal (for storage in adherenceCheckIds).
+ */
+ private static Integer mapAdherenceEnumToOrdinal(AdherenceCheckScheduleEnumDTO enumValue) {
+ if (enumValue == null) return null;
+ return enumValue.ordinal();
+ }
+
+ private static GoalTemplateCategoriesDTO.KindEnum mapKindToEnum(String kind) {
+ if (kind == null) return null;
+ try {
+ return GoalTemplateCategoriesDTO.KindEnum.fromValue(kind.toLowerCase().trim());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/studymanager/src/main/resources/logback-spring.xml b/studymanager/src/main/resources/logback-spring.xml
index 1309c2bd..bbc04e6a 100644
--- a/studymanager/src/main/resources/logback-spring.xml
+++ b/studymanager/src/main/resources/logback-spring.xml
@@ -17,7 +17,12 @@
-
+
+
+
+
+
+
diff --git a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml
index d39c69c3..aa44be58 100644
--- a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml
+++ b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml
@@ -1318,6 +1318,24 @@ paths:
# --- START GOALS
/studies/{studyId}/goals/config:
+ get:
+ tags:
+ - goals
+ description: Getter for the goal configuration
+ operationId: getGoalConfig
+ parameters:
+ - $ref: '#/components/parameters/StudyId'
+ responses:
+ '200':
+ description: Study goal configuration updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StudyGoalConfigData'
+ '404':
+ description: The referenced study does not exist
+ '400':
+ description: The parsed studyId is invalid
put:
tags:
- goals
@@ -1337,9 +1355,11 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/StudyGoalConfig'
+ $ref: '#/components/schemas/StudyGoalConfigData'
+ '404':
+ description: The referenced study does not exist
'400':
- description: The parsed study goal configuration was invalid
+ description: The parsed study goal configuration or study id was invalid
/studies/{studyId}/goals/config/categories/topic:
post:
@@ -2588,15 +2608,29 @@ components:
$ref: '#/components/schemas/AdherenceCheckScheduleEnum'
time:
type: string
- topics:
- type: array
- items:
- $ref: '#/components/schemas/GoalTopic'
+ format: time
+ description: Follows ISO 8601 format for time
+ required:
+ - consents
+ - schedule
+
+ StudyGoalConfigData:
+ allOf:
+ - $ref: "#/components/schemas/StudyGoalConfig"
+ - type: object
+ properties:
+ topics:
+ type: array
+ items:
+ $ref: '#/components/schemas/GoalTopic'
required:
- consents
- schedule
- topics
+
+
+
AdherenceCheckScheduleEnum:
type: string
enum:
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ComponentControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ComponentControllerTest.java
index ec42215c..0a45c6ee 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ComponentControllerTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ComponentControllerTest.java
@@ -9,25 +9,54 @@
package io.redlink.more.studymanager.controller.studymanager;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import io.redlink.more.studymanager.core.factory.ActionFactory;
+import io.redlink.more.studymanager.core.factory.GoalTemplateFactory;
import io.redlink.more.studymanager.core.factory.ObservationFactory;
+import io.redlink.more.studymanager.core.factory.TriggerFactory;
import io.redlink.more.studymanager.core.model.User;
+import io.redlink.more.studymanager.core.properties.ObservationProperties;
+import io.redlink.more.studymanager.core.properties.model.BooleanValue;
+import io.redlink.more.studymanager.core.properties.model.IntegerRange;
+import io.redlink.more.studymanager.core.properties.model.IntegerRangeValue;
+import io.redlink.more.studymanager.core.properties.model.IntegerValue;
+import io.redlink.more.studymanager.core.properties.model.StringTextValue;
+import io.redlink.more.studymanager.core.properties.model.StringValue;
import io.redlink.more.studymanager.model.AuthenticatedUser;
import io.redlink.more.studymanager.service.OAuth2AuthenticationService;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.stream.Stream;
+
+import org.junit.Before;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.web.context.WebApplicationContext;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -36,15 +65,93 @@
@AutoConfigureMockMvc(addFilters = false)
class ComponentControllerTest {
+ private static ObjectMapper MAPPER = new ObjectMapper()
+ .registerModule(new JavaTimeModule());
+
+
@MockitoBean
private OAuth2AuthenticationService authenticationService;
- @MockitoBean(name = "my-test-observation")
- private ObservationFactory factory;
-
@Autowired
private MockMvc mvc;
+ //Test configuration that registers a observation-, trigger-, action- and goalTemplateFactory for testing
+ @TestConfiguration
+ static class TestComponentConfig {
+
+ ObservationFactory observationFactory;
+ TriggerFactory triggerFactory;
+ ActionFactory actionFactory;
+ GoalTemplateFactory goalTemplateFactory;
+
+ public TestComponentConfig(){
+ this.observationFactory = mock(ObservationFactory.class);
+ when(observationFactory.getId()).thenReturn("my-test-observation");
+ when(observationFactory.getPropertyClass()).thenReturn(ObservationProperties.class);
+ when(observationFactory.getProperties()).thenReturn(List.of(
+ new BooleanValue("test-boolean")
+ .setName("Boolean Value Test")
+ .setRequired(true)
+ .setImmutable(false)
+ .setDefaultValue(false),
+ new IntegerValue("test-integer")
+ .setName("Integer Value Test")
+ .setRequired(true)
+ .setImmutable(false)
+ .setDefaultValue(-1),
+ new StringValue("test-string")
+ .setName("String Value Test")
+ .setRequired(true)
+ .setImmutable(false)
+ .setDefaultValue("default"),
+ new StringTextValue("test-text")
+ .setName("Text Value Test")
+ .setRequired(true)
+ .setImmutable(false)
+ .setDefaultValue("default\nmultiline"),
+ new IntegerRangeValue("test-range")
+ .setMin(0)
+ .setMax(100)
+ .setName("Range Value Test")
+ .setRequired(true)
+ .setImmutable(false)
+ .setDefaultValue(new IntegerRange(1,50))
+ ));
+
+ this.triggerFactory = mock(TriggerFactory.class);
+ when(triggerFactory.getId()).thenReturn("my-test-trigger");
+
+ this.actionFactory = mock(ActionFactory.class);
+ when(actionFactory.getId()).thenReturn("my-test-action");
+
+ this.goalTemplateFactory = mock(GoalTemplateFactory.class);
+ when(goalTemplateFactory.getId()).thenReturn("my-test-goal-template");
+ }
+
+ @Bean("my-test-observation")
+ public ObservationFactory getObservationFactory() {
+ return observationFactory;
+ }
+
+ @Bean("my-test-trigger")
+ public TriggerFactory getTriggerFactory() {
+ return triggerFactory;
+ }
+
+ @Bean("my-test-action")
+ public ActionFactory getActionFactory() {
+ return actionFactory;
+ }
+
+ @Bean("my-test-goal-template")
+ public GoalTemplateFactory getGoalTemplateFactory() {
+ return goalTemplateFactory;
+ }
+ }
+
+ @Autowired
+ private TestComponentConfig testComponentConfig;
+
@Captor
ArgumentCaptor jsonNodeArgumentCaptor;
@@ -59,9 +166,37 @@ void testComponentSpecificEndpointExists() throws Exception {
.content("{\"hello\":\"world\"}"))
.andExpect(status().isOk());
- verify(factory).handleAPICall(anyString(), any(User.class), jsonNodeArgumentCaptor.capture());
+ verify(testComponentConfig.observationFactory).handleAPICall(anyString(), any(User.class), jsonNodeArgumentCaptor.capture());
String value = jsonNodeArgumentCaptor.getValue().get("hello").asText();
Assertions.assertEquals("world", value);
+
+ mvc.perform(MockMvcRequestBuilders.post("/api/v1/components/action/my-test-action/api/my-test-slug")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("{\"hello\":\"world\"}"))
+ .andExpect(status().isOk());
+
+ verify(testComponentConfig.observationFactory).handleAPICall(anyString(), any(User.class), jsonNodeArgumentCaptor.capture());
+ value = jsonNodeArgumentCaptor.getValue().get("hello").asText();
+ Assertions.assertEquals("world", value);
+
+ mvc.perform(MockMvcRequestBuilders.post("/api/v1/components/trigger/my-test-trigger/api/my-test-slug")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("{\"hello\":\"world\"}"))
+ .andExpect(status().isOk());
+
+ verify(testComponentConfig.observationFactory).handleAPICall(anyString(), any(User.class), jsonNodeArgumentCaptor.capture());
+ value = jsonNodeArgumentCaptor.getValue().get("hello").asText();
+ Assertions.assertEquals("world", value);
+
+ mvc.perform(MockMvcRequestBuilders.post("/api/v1/components/goalTemplate/my-test-goal-template/api/my-test-slug")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("{\"hello\":\"world\"}"))
+ .andExpect(status().isOk());
+
+ verify(testComponentConfig.observationFactory).handleAPICall(anyString(), any(User.class), jsonNodeArgumentCaptor.capture());
+ value = jsonNodeArgumentCaptor.getValue().get("hello").asText();
+ Assertions.assertEquals("world", value);
+
}
@Test
@@ -70,6 +205,43 @@ void testComponentSpecificEndpointDoesNotExist() throws Exception {
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content("{}"))
.andExpect(status().isNotFound());
+
+ mvc.perform(MockMvcRequestBuilders.post("/api/v1/components/trigger/another-test-trigger/api/my-test-slug")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("{}"))
+ .andExpect(status().isNotFound());
+
+ mvc.perform(MockMvcRequestBuilders.post("/api/v1/components/action/another-test-action/api/my-test-slug")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("{}"))
+ .andExpect(status().isNotFound());
+
+ mvc.perform(MockMvcRequestBuilders.post("/api/v1/components/goalTemplate/another-test-goal-template/api/my-test-slug")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("{}"))
+ .andExpect(status().isNotFound());
+
}
+ @Test
+ public void testComponentProperties() throws Exception {
+
+ Map properties = new HashMap<>();
+ properties.put("test-boolean", true);
+ properties.put("test-integer", 1);
+ properties.put("test-string", "test");
+ properties.put("test-text", "test\nmultiline");
+ properties.put("test-range", Map.of("lower", 1, "upper", 10));
+ String content = MAPPER.writeValueAsString(properties);
+ AuthenticatedUser user = new AuthenticatedUser("user1", "", "","", Set.of());
+ when(authenticationService.getCurrentUser()).thenReturn(user);
+ mvc.perform(MockMvcRequestBuilders.post("/api/v1/components/observation/my-test-observation/validate")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content(content))
+ .andExpect(status().isOk());
+
+
+ }
+
+
}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/GoalsApiV1ControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/GoalsApiV1ControllerTest.java
new file mode 100644
index 00000000..04294a1e
--- /dev/null
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/GoalsApiV1ControllerTest.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
+ * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
+ * for Digital Health and Prevention -- A research institute of the
+ * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur
+ * Förderung der wissenschaftlichen Forschung).
+ * Licensed under the Elastic License 2.0.
+ */
+package io.redlink.more.studymanager.controller.studymanager;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.redlink.more.studymanager.api.v1.model.*;
+import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.transformer.GoalV1Transformer;
+import io.redlink.more.studymanager.service.GoalService;
+import io.redlink.more.studymanager.service.OAuth2AuthenticationService;
+import io.redlink.more.studymanager.service.StudyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.*;
+import java.util.*;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@WebMvcTest({GoalsApiV1Controller.class})
+@AutoConfigureMockMvc(addFilters = false)
+class GoalsApiV1ControllerTest {
+
+ @MockitoBean
+ StudyService studyService;
+
+ @MockitoBean
+ GoalService goalService;
+
+ @MockitoBean
+ OAuth2AuthenticationService oAuth2AuthenticationService;
+
+ @Autowired
+ ObjectMapper mapper;
+
+ @Autowired
+ private MockMvc mvc;
+
+ private static final Long STUDY_ID = 42L;
+ private static final String USER_ID = UUID.randomUUID().toString();
+
+ @BeforeEach
+ void setUp() {
+ // Mock authenticated user
+ when(oAuth2AuthenticationService.getCurrentUser()).thenReturn(
+ new AuthenticatedUser(USER_ID, "Test User", "test@example.com", "Test Inc.",
+ EnumSet.allOf(PlatformRole.class))
+ );
+
+ // Mock study validation (used by every endpoint)
+ when(studyService.getStudy(anyLong(), any(AuthenticatedUser.class)))
+ .thenReturn(Optional.of(new Study().setStudyId(STUDY_ID)));
+ }
+
+ /* ==================== Goal Config ==================== */
+
+ @Test
+ @DisplayName("GET goal config - should return full config with topics and schedule")
+ void testGetGoalConfig() throws Exception {
+ StudyGoalConfig config = new StudyGoalConfig()
+ .setStudyId(STUDY_ID)
+ .setCommitment("strong")
+ .setAchievability("medium")
+ .setUnderstandability("high");
+
+ List topics = List.of(
+ new GoalTopic().setStudyId(STUDY_ID).setKey("physical").setTitle("Physical Activity")
+ );
+
+ List checks = List.of(
+ new GoalAdherenceCheck().setStudyId(STUDY_ID)
+ .setTitle("morning")
+ .setTime(LocalTime.of(8, 0))
+ );
+
+ when(goalService.getGoalConfig(STUDY_ID)).thenReturn(config);
+ when(goalService.getGoalTopics(STUDY_ID)).thenReturn(topics);
+ when(goalService.getGoalAdherenceChecks(STUDY_ID)).thenReturn(checks);
+
+ mvc.perform(get("/api/v1/studies/{studyId}/goals/config", STUDY_ID))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.consents.commitment").value("strong"))
+ .andExpect(jsonPath("$.consents.achievability").value("medium"))
+ .andExpect(jsonPath("$.consents.understandable").value("high"))
+ .andExpect(jsonPath("$.topics[0].key").value("physical"))
+ .andExpect(jsonPath("$.schedule[0].key").value("morning"))
+ .andExpect(jsonPath("$.schedule[0].time").value("08:00:00"));
+ }
+
+ @Test
+ @DisplayName("GET goal config - returns default empty")
+ void testGetGoalConfigNotFound() throws Exception {
+ when(goalService.getGoalConfig(STUDY_ID)).thenReturn(null);
+
+ mvc.perform(get("/api/v1/studies/{studyId}/goals/config", STUDY_ID))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.consents.commitment").isEmpty())
+ .andExpect(jsonPath("$.consents.achievability").isEmpty())
+ .andExpect(jsonPath("$.consents.understandable").isEmpty())
+ .andExpect(jsonPath("$.topics").isArray())
+ .andExpect(jsonPath("$.topics").isEmpty())
+ .andExpect(jsonPath("$.schedule").isArray())
+ .andExpect(jsonPath("$.schedule").isEmpty());
+ }
+
+ @Test
+ @DisplayName("PUT goal config - should update and return full data")
+ void testSetGoalConfig() throws Exception {
+ StudyGoalConfigDTO request = new StudyGoalConfigDTO()
+ .consents(new StudyGoalConfigConsentsDTO()
+ .commitment("strong")
+ .achievability("medium")
+ .understandable("high"))
+ .schedule(List.of(new StudyGoalConfigScheduleInnerDTO()
+ .key(AdherenceCheckScheduleEnumDTO.MORNING)
+ .time(LocalTime.of(9, 0))));
+
+ StudyGoalConfig updatedConfig = new StudyGoalConfig()
+ .setStudyId(STUDY_ID)
+ .setCommitment("strong")
+ .setAchievability("medium")
+ .setUnderstandability("high");
+
+ List updatedChecks = List.of(
+ new GoalAdherenceCheck().setStudyId(STUDY_ID).setTitle("morning").setTime(LocalTime.of(9, 0))
+ );
+
+ when(goalService.setGoalConfig(any(StudyGoalConfig.class))).thenReturn(updatedConfig);
+ when(goalService.setGoalAdherenceChecks(anyLong(), any())).thenReturn(updatedChecks);
+ when(goalService.getGoalTopics(STUDY_ID)).thenReturn(List.of());
+
+ mvc.perform(put("/api/v1/studies/{studyId}/goals/config", STUDY_ID)
+ .content(mapper.writeValueAsString(request))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.consents.commitment").value("strong"))
+ .andExpect(jsonPath("$.schedule[0].key").value("morning"))
+ .andExpect(jsonPath("$.schedule[0].time").value("09:00:00"));
+ }
+
+ /* ==================== Goal Topics ==================== */
+
+ @Test
+ @DisplayName("POST goal topic - creates topic with auto-generated slug")
+ void testCreateGoalTopic() throws Exception {
+ GoalTopicDTO request = new GoalTopicDTO()
+ .title("Physical Activity")
+ .description("Daily movement goals");
+
+ GoalTopic created = new GoalTopic()
+ .setStudyId(STUDY_ID)
+ .setKey("physical-activity")
+ .setTitle("Physical Activity")
+ .setDescription("Daily movement goals");
+
+ when(goalService.getGoalTopic(STUDY_ID, "physical-activity")).thenReturn(null);
+ when(goalService.setGoalTopic(any(GoalTopic.class))).thenReturn(created);
+
+ mvc.perform(post("/api/v1/studies/{studyId}/goals/config/categories/topic", STUDY_ID)
+ .content(mapper.writeValueAsString(request))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.key").value("physical-activity"))
+ .andExpect(jsonPath("$.title").value("Physical Activity"));
+ }
+
+ @Test
+ @DisplayName("POST goal topic - conflict when key exists")
+ void testCreateGoalTopicConflict() throws Exception {
+ GoalTopicDTO request = new GoalTopicDTO().title("Physical Activity").key("physical");
+
+ when(goalService.getGoalTopic(STUDY_ID, "physical")).thenReturn(new GoalTopic());
+
+ mvc.perform(post("/api/v1/studies/{studyId}/goals/config/categories/topic", STUDY_ID)
+ .content(mapper.writeValueAsString(request))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isConflict());
+ }
+
+ @Test
+ @DisplayName("PUT goal topic - updates existing topic")
+ void testUpdateGoalTopic() throws Exception {
+ GoalTopicDTO request = new GoalTopicDTO()
+ .key("physical")
+ .title("Updated Title")
+ .description("New desc");
+
+ GoalTopic updated = new GoalTopic()
+ .setStudyId(STUDY_ID)
+ .setKey("physical")
+ .setTitle("Updated Title")
+ .setDescription("New desc");
+
+ when(goalService.setGoalTopic(any(GoalTopic.class))).thenReturn(updated);
+
+ mvc.perform(put("/api/v1/studies/{studyId}/goals/config/categories/topic/{key}", STUDY_ID, "physical")
+ .content(mapper.writeValueAsString(request))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.title").value("Updated Title"));
+ }
+
+ @Test
+ @DisplayName("DELETE goal topic - returns 204")
+ void testDeleteGoalTopic() throws Exception {
+ mvc.perform(delete("/api/v1/studies/{studyId}/goals/config/categories/topic/{key}", STUDY_ID, "physical"))
+ .andDo(print())
+ .andExpect(status().isNoContent());
+ }
+
+ /* ==================== Goal Templates ==================== */
+
+ @Test
+ @DisplayName("GET goal templates - returns list")
+ void testListGoalTemplates() throws Exception {
+ GoalTemplate template = new GoalTemplate()
+ .setStudyId(STUDY_ID)
+ .setTemplateId(1)
+ .setTitle("Daily Steps")
+ .setType("steps")
+ .setKind("behavioral");
+
+ when(goalService.listGoalTemplates(STUDY_ID)).thenReturn(List.of(template));
+
+ mvc.perform(get("/api/v1/studies/{studyId}/goals/templates", STUDY_ID))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].templateId").value(1))
+ .andExpect(jsonPath("$[0].title").value("Daily Steps"));
+ }
+
+ @Test
+ @DisplayName("POST goal template - creates and returns template")
+ void testAddGoalTemplate() throws Exception {
+ GoalTemplateDTO request = new GoalTemplateDTO()
+ .studyId(STUDY_ID)
+ .title("Daily Steps")
+ .participantTitle("Walk 10k steps")
+ .participantInfo("Info")
+ .type("steps")
+ .categories(new GoalTemplateCategoriesDTO()
+ .kind(GoalTemplateCategoriesDTO.KindEnum.BEHAVIORAL)
+ .topics(List.of("physical")));
+
+ GoalTemplate created = new GoalTemplate()
+ .setStudyId(STUDY_ID)
+ .setTemplateId(10)
+ .setTitle("Daily Steps")
+ .setType("steps");
+
+ when(goalService.addGoalTemplate(any(GoalTemplate.class))).thenReturn(created);
+
+ mvc.perform(post("/api/v1/studies/{studyId}/goals/templates", STUDY_ID)
+ .content(mapper.writeValueAsString(request))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.templateId").value(10))
+ .andExpect(jsonPath("$.title").value("Daily Steps"));
+ }
+
+ @Test
+ @DisplayName("PUT goal template - updates template")
+ void testUpdateGoalTemplate() throws Exception {
+ GoalTemplateDTO request = new GoalTemplateDTO()
+ .templateId(5)
+ .title("Updated Template");
+
+ GoalTemplate updated = new GoalTemplate()
+ .setStudyId(STUDY_ID)
+ .setTemplateId(5)
+ .setTitle("Updated Template");
+
+ when(goalService.updateGoalTemplate(any(GoalTemplate.class))).thenReturn(updated);
+
+ mvc.perform(put("/api/v1/studies/{studyId}/goals/templates/{templateId}", STUDY_ID, 5)
+ .content(mapper.writeValueAsString(request))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.title").value("Updated Template"));
+ }
+
+ @Test
+ @DisplayName("DELETE goal template - returns 204")
+ void testDeleteGoalTemplate() throws Exception {
+ mvc.perform(delete("/api/v1/studies/{studyId}/goals/templates/{templateId}", STUDY_ID, 5))
+ .andDo(print())
+ .andExpect(status().isNoContent());
+ }
+
+ /* ==================== Error Cases ==================== */
+
+ @Test
+ @DisplayName("Invalid study returns 404 via validation")
+ void testStudyNotFound() throws Exception {
+ when(studyService.getStudy(anyLong(), any(AuthenticatedUser.class))).thenReturn(Optional.empty());
+
+ mvc.perform(get("/api/v1/studies/{studyId}/goals/config", STUDY_ID))
+ .andExpect(status().isNotFound());
+ }
+}
\ No newline at end of file