diff --git a/pom.xml b/pom.xml index 59bc5de5..21b8a751 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ studymanager-core studymanager-observation studymanager-intervention + studymanager-goaltemplates studymanager-services studymanager diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/factory/ComponentFactory.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/factory/ComponentFactory.java index cbfee8c3..5a240947 100644 --- a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/factory/ComponentFactory.java +++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/factory/ComponentFactory.java @@ -36,14 +36,14 @@ public ComponentFactory init(ComponentFactoryProperties componentProperties public abstract String getTitle(); + public abstract String getDescription(); + public List 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 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 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