diff --git a/.github/workflows/compile-test.yml b/.github/workflows/compile-test.yml
index e6ca1133..57b77763 100644
--- a/.github/workflows/compile-test.yml
+++ b/.github/workflows/compile-test.yml
@@ -1,6 +1,10 @@
name: Test and Compile
on:
workflow_dispatch:
+ inputs:
+ dockerTag:
+ description: If set, docker img is built and tagged accordingly
+ required: false
push:
jobs:
@@ -10,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
@@ -37,28 +41,44 @@ jobs:
Build-and-Deploy:
name: "Build and Push Docker Image"
runs-on: ubuntu-latest
- if: github.ref_name == 'main'
+ if: contains(fromJSON('["main", "develop", "redlink", "staging"]'), github.ref_name) || github.event.inputs.dockerTag != ''
needs:
- Compile-and-Test
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
+ - name: Generate additional Docker-Tags
+ run: |
+ TAGS=${BRANCH}
+ if [ "$BRANCH" == "$MAIN_BRANCH" ]; then
+ TAGS="latest,$TAGS"
+ fi
+ if [ -n "$EVENT_PARAM" ]; then
+ TAGS="$EVENT_PARAM"
+ fi
+ echo "Generated Docker-Tags: $TAGS"
+ echo "TAGS=$TAGS" >> "$GITHUB_ENV"
+ env:
+ BRANCH: ${{ github.ref_name }}
+ MAIN_BRANCH: ${{ github.event.repository.default_branch }}
+ EVENT_PARAM: ${{ github.event.inputs.dockerTag }}
- name: Build JIB container and publish to GitHub Packages
- run: ./mvnw -B -U
- --no-transfer-progress
- clean deploy
- -Drevision=${{github.run_number}}
- -Dchangelist=
- -Dsha1=.${GITHUB_SHA:0:7}
- -Dquick
- -Ddocker.namespace=${DOCKER_NAMESPACE,,}
- -Djib.to.tags=latest
- -Djib.to.auth.username=${{ github.actor }}
- -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }}
+ run:
+ ./mvnw -B -U
+ --no-transfer-progress
+ clean deploy
+ -Drevision=${{github.run_number}}
+ -Dchangelist=
+ -Dsha1=.${GITHUB_SHA:0:7}
+ -Dquick
+ -Ddocker.namespace=${DOCKER_NAMESPACE,,}
+ -Djib.to.tags=${TAGS}
+ -Djib.to.auth.username=${{ github.actor }}
+ -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }}
env:
DOCKER_NAMESPACE: ghcr.io/${{ github.repository_owner }}
diff --git a/pom.xml b/pom.xml
index c7d8ad44..780a1c14 100644
--- a/pom.xml
+++ b/pom.xml
@@ -35,8 +35,8 @@
UTF-8
17
- 3.1.3
- 1.19.0
+ 3.3.1
+ 1.19.8
more-project
@@ -57,7 +57,7 @@
org.codehaus.mojo
flatten-maven-plugin
- 1.5.0
+ 1.6.0
${project.build.directory}
@@ -84,12 +84,12 @@
org.apache.maven.plugins
maven-surefire-plugin
- 3.1.2
+ 3.2.5
org.apache.maven.plugins
maven-compiler-plugin
- 3.11.0
+ 3.13.0
${java.version}
@@ -100,7 +100,7 @@
org.apache.maven.plugins
maven-install-plugin
- 3.1.1
+ 3.1.2
${maven.install.skip}
@@ -108,7 +108,7 @@
org.apache.maven.plugins
maven-deploy-plugin
- 3.1.1
+ 3.1.2
true
@@ -128,7 +128,7 @@
org.codehaus.mojo
license-maven-plugin
- 2.2.0
+ 2.4.0
generate-third-party
@@ -319,7 +319,7 @@
com.google.cloud.tools
jib-maven-plugin
- 3.4.0
+ 3.4.3
jib-deploy
@@ -408,12 +408,12 @@
co.elastic.clients
elasticsearch-java
- 8.10.2
+ 8.13.4
com.google.firebase
firebase-admin
- 9.2.0
+ 9.3.0
org.apache.httpcomponents
@@ -429,7 +429,7 @@
com.mchange
c3p0
- 0.9.5.5
+ 0.10.1
@@ -440,17 +440,17 @@
jakarta.json
jakarta.json-api
- 2.1.2
+ 2.1.3
com.google.guava
guava
- 32.1.2-jre
+ 33.2.1-jre
net.sf.biweekly
biweekly
- 0.6.7
+ 0.6.8
diff --git a/studymanager-core/pom.xml b/studymanager-core/pom.xml
index 4a0bc8e0..22bbcd51 100644
--- a/studymanager-core/pom.xml
+++ b/studymanager-core/pom.xml
@@ -16,12 +16,11 @@
com.fasterxml.jackson.core
jackson-databind
- 2.15.2
org.slf4j
slf4j-api
- 2.0.9
+ 2.0.13
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 3e3f7902..cbfee8c3 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
@@ -77,6 +77,8 @@ public P validate(P values) {
}
}
+ public P preImport(P values) { return values; }
+
public WebComponent getWebComponent() {
return null;
}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/ActionParameter.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/ActionParameter.java
index 19f51834..5f9b5918 100644
--- a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/ActionParameter.java
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/ActionParameter.java
@@ -42,4 +42,12 @@ public boolean equals(Object o) {
public int hashCode() {
return Objects.hash(super.hashCode(), studyId, participantId);
}
+
+ @Override
+ public String toString() {
+ return "ActionParameter{" +
+ "studyId=" + studyId +
+ ", participantId=" + participantId +
+ '}';
+ }
}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/SimpleParticipant.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/SimpleParticipant.java
new file mode 100644
index 00000000..08628dee
--- /dev/null
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/SimpleParticipant.java
@@ -0,0 +1,21 @@
+package io.redlink.more.studymanager.core.io;
+
+import java.time.Instant;
+
+public class SimpleParticipant {
+ private final Integer id;
+ private final Instant start;
+
+ public SimpleParticipant(Integer id, Instant start) {
+ this.id = id;
+ this.start = start;
+ }
+
+ public Integer getId() {
+ return id;
+ }
+
+ public Instant getStart() {
+ return start;
+ }
+}
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/TriggerResult.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/TriggerResult.java
index 46ba3504..b53ce9c0 100644
--- a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/TriggerResult.java
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/io/TriggerResult.java
@@ -22,7 +22,11 @@ public TriggerResult(Set actionParameters, boolean proceed) {
public static TriggerResult NOOP = new TriggerResult(null, false);
public static TriggerResult withParams(Set actionParameterSet) {
- return new TriggerResult(actionParameterSet, true);
+ if(actionParameterSet.isEmpty()) {
+ return TriggerResult.NOOP;
+ } else {
+ return new TriggerResult(actionParameterSet, true);
+ }
}
public Set getActionParameters() {
diff --git a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/sdk/MorePlatformSDK.java b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/sdk/MorePlatformSDK.java
index f601e01c..cbfc9881 100644
--- a/studymanager-core/src/main/java/io/redlink/more/studymanager/core/sdk/MorePlatformSDK.java
+++ b/studymanager-core/src/main/java/io/redlink/more/studymanager/core/sdk/MorePlatformSDK.java
@@ -8,6 +8,8 @@
*/
package io.redlink.more.studymanager.core.sdk;
+import io.redlink.more.studymanager.core.io.SimpleParticipant;
+
import java.io.Serializable;
import java.util.Optional;
import java.util.Set;
@@ -21,6 +23,7 @@ public enum ParticipantFilter {
Optional getValue(String name, Class tClass);
void removeValue(String name);
Set participantIds(ParticipantFilter filter);
+ Set participants(ParticipantFilter filter);
long getStudyId();
Integer getStudyGroupId();
// TODO
diff --git a/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/action/PushNotificationAction.java b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/action/PushNotificationAction.java
index ca2eb2c5..beb65b9d 100644
--- a/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/action/PushNotificationAction.java
+++ b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/action/PushNotificationAction.java
@@ -13,15 +13,19 @@
import io.redlink.more.studymanager.core.io.ActionParameter;
import io.redlink.more.studymanager.core.properties.ActionProperties;
import io.redlink.more.studymanager.core.sdk.MoreActionSDK;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class PushNotificationAction extends Action {
+ private static final Logger LOGGER = LoggerFactory.getLogger(PushNotificationAction.class);
protected PushNotificationAction(MoreActionSDK sdk, ActionProperties properties) throws ConfigurationValidationException {
super(sdk, properties);
}
@Override
public void execute(ActionParameter parameters) {
+ LOGGER.info("send push notification with parameters: {}", parameters.toString());
sdk.sendPushNotification(
properties.getString("title"),
properties.getString("message")
diff --git a/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTrigger.java b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTrigger.java
new file mode 100644
index 00000000..3a306f93
--- /dev/null
+++ b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTrigger.java
@@ -0,0 +1,62 @@
+package io.redlink.more.studymanager.component.trigger.relative;
+
+import io.redlink.more.studymanager.core.component.Trigger;
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.io.ActionParameter;
+import io.redlink.more.studymanager.core.io.Parameters;
+import io.redlink.more.studymanager.core.io.SimpleParticipant;
+import io.redlink.more.studymanager.core.io.TriggerResult;
+import io.redlink.more.studymanager.core.properties.TriggerProperties;
+import io.redlink.more.studymanager.core.sdk.MorePlatformSDK;
+import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK;
+import io.redlink.more.studymanager.core.sdk.schedule.CronSchedule;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.stream.Collectors;
+
+public class RelativeTimeTrigger extends Trigger {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RelativeTimeTrigger.class);
+
+ // TODO set to study home as soon the feature is there
+ private static final ZoneId HOME = ZoneId.of("Europe/Vienna");
+
+ protected RelativeTimeTrigger(MoreTriggerSDK sdk, TriggerProperties properties) throws ConfigurationValidationException {
+ super(sdk, properties);
+ }
+ @Override
+ public void activate() {
+ String schedule = sdk.addSchedule(new CronSchedule("1 0 * * * ?"));
+ sdk.setValue("scheduleId", schedule);
+ }
+
+ @Override
+ public void deactivate() {
+ sdk.getValue("scheduleId", String.class).ifPresent(sdk::removeSchedule);
+ }
+
+ @Override
+ public TriggerResult execute(Parameters parameters) {
+ return TriggerResult.withParams(
+ sdk.participants(MorePlatformSDK.ParticipantFilter.ACTIVE_ONLY).stream()
+ .filter(p -> matchesDayAndHour(p, Instant.now()))
+ .map(p -> new ActionParameter(sdk.getStudyId(), p.getId()))
+ .collect(Collectors.toSet())
+ );
+ }
+
+ protected boolean matchesDayAndHour(SimpleParticipant participant, Instant now) {
+ long day = ChronoUnit.DAYS.between(
+ LocalDateTime.of(participant.getStart().atZone(HOME).toLocalDate(), LocalTime.MIDNIGHT),
+ LocalDateTime.of(now.atZone(HOME).toLocalDate(), LocalTime.MIDNIGHT)
+ ) + 1;
+ int hour = now.atZone(HOME).getHour();
+ return properties.getInt("hour") == hour && properties.getLong("day") == day;
+ }
+}
diff --git a/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTriggerFactory.java b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTriggerFactory.java
new file mode 100644
index 00000000..9b96b0c9
--- /dev/null
+++ b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTriggerFactory.java
@@ -0,0 +1,54 @@
+package io.redlink.more.studymanager.component.trigger.relative;
+
+import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
+import io.redlink.more.studymanager.core.factory.TriggerFactory;
+import io.redlink.more.studymanager.core.properties.TriggerProperties;
+import io.redlink.more.studymanager.core.properties.model.IntegerValue;
+import io.redlink.more.studymanager.core.properties.model.Value;
+import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK;
+
+import java.util.List;
+
+public class RelativeTimeTriggerFactory extends TriggerFactory {
+
+ private static List properties = List.of(
+ new IntegerValue("day")
+ .setMin(1)
+ .setDefaultValue(1)
+ .setName("intervention.factory.trigger.relativeTime.configProps.day")
+ .setDescription("intervention.factory.trigger.relativeTime.configProps.dayDesc")
+ .setRequired(true),
+ new IntegerValue("hour")
+ .setMin(0)
+ .setMax(23)
+ .setDefaultValue(1)
+ .setName("intervention.factory.trigger.relativeTime.configProps.hour")
+ .setDescription("intervention.factory.trigger.relativeTime.configProps.hourDesc")
+ .setRequired(true)
+ );
+
+ @Override
+ public String getId() {
+ return "relative-time-trigger";
+ }
+
+ @Override
+ public String getTitle() {
+ return "intervention.factory.trigger.relativeTime.title";
+ }
+
+ @Override
+ public String getDescription() {
+ return "intervention.factory.trigger.relativeTime.description";
+ }
+
+ @Override
+ public List getProperties() {
+ return properties;
+ }
+
+ @Override
+ public RelativeTimeTrigger create(MoreTriggerSDK sdk, TriggerProperties properties) throws ConfigurationValidationException {
+ return new RelativeTimeTrigger(sdk, properties);
+ }
+}
diff --git a/studymanager-intervention/src/test/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTriggerTest.java b/studymanager-intervention/src/test/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTriggerTest.java
new file mode 100644
index 00000000..62bec3a8
--- /dev/null
+++ b/studymanager-intervention/src/test/java/io/redlink/more/studymanager/component/trigger/relative/RelativeTimeTriggerTest.java
@@ -0,0 +1,31 @@
+package io.redlink.more.studymanager.component.trigger.relative;
+
+import io.redlink.more.studymanager.core.io.SimpleParticipant;
+import io.redlink.more.studymanager.core.properties.TriggerProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Map;
+
+class RelativeTimeTriggerTest {
+
+ private static final ZoneId HOME = ZoneId.of("Europe/Vienna");
+ @Test
+ void testParticipantFilter() {
+ TriggerProperties properties = new TriggerProperties(Map.of(
+ "day", 2, "hour", 8
+ ));
+ RelativeTimeTrigger trigger = new RelativeTimeTrigger(null, properties);
+ Instant now1 = Instant.parse("2024-01-23T10:00:01.00Z");
+ Instant now2 = Instant.parse("2024-01-23T07:00:01.00Z");
+ SimpleParticipant p1 = new SimpleParticipant(1, Instant.parse("2024-01-21T11:13:01.00Z"));
+ SimpleParticipant p2 = new SimpleParticipant(1, Instant.parse("2024-01-22T10:14:01.00Z"));
+
+ Assertions.assertFalse(trigger.matchesDayAndHour(p1, now1));
+ Assertions.assertFalse(trigger.matchesDayAndHour(p2, now1));
+ Assertions.assertFalse(trigger.matchesDayAndHour(p1, now2));
+ Assertions.assertTrue(trigger.matchesDayAndHour(p2, now2));
+ }
+}
diff --git a/studymanager-observation/pom.xml b/studymanager-observation/pom.xml
index 1d3aa50b..ac33d918 100644
--- a/studymanager-observation/pom.xml
+++ b/studymanager-observation/pom.xml
@@ -22,12 +22,11 @@
com.fasterxml.jackson.core
jackson-databind
- 2.15.2
com.google.code.gson
gson
- 2.10.1
+ 2.11.0
org.springframework.boot
diff --git a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java
index ac0ee493..8d672ea9 100644
--- a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java
+++ b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java
@@ -20,6 +20,7 @@
public class LimeSurveyObservation extends Observation {
+ public static final String LIME_SURVEY_ID = "limeSurveyId";
private final LimeSurveyRequestService limeSurveyRequestService;
public LimeSurveyObservation(MoreObservationSDK sdk, C properties, LimeSurveyRequestService limeSurveyRequestService) throws ConfigurationValidationException {
@@ -29,9 +30,9 @@ public LimeSurveyObservation(MoreObservationSDK sdk, C properties, LimeSurveyReq
@Override
public void activate(){
+ String surveyId = checkAndGetSurveyId();
+
Set participantIds = sdk.participantIds(MorePlatformSDK.ParticipantFilter.ALL);
- String surveyId = properties.getString("limeSurveyId");
- //TODO disable keys fromm removed?
participantIds.removeIf(id -> sdk.getPropertiesForParticipant(id).isPresent());
limeSurveyRequestService.activateParticipants(participantIds, surveyId)
.forEach(data -> {
@@ -45,6 +46,36 @@ public void activate(){
});
limeSurveyRequestService.setSurveyEndUrl(surveyId, sdk.getStudyId(), sdk.getObservationId());
limeSurveyRequestService.activateSurvey(surveyId);
+ sdk.setValue(LIME_SURVEY_ID, surveyId);
+ }
+
+ protected String checkAndGetSurveyId() {
+ String newSurveyId = properties.getString(LIME_SURVEY_ID);
+ String activeSurveyId = sdk.getValue(LIME_SURVEY_ID, String.class).orElse(null);
+
+ if(activeSurveyId != null && !activeSurveyId.equals(newSurveyId)) {
+ throw new RuntimeException(String.format(
+ "SurveyId on Observation %s must not be changed: %s -> %s",
+ sdk.getObservationId(),
+ activeSurveyId,
+ newSurveyId
+ ));
+ } else {
+ return newSurveyId;
+ }
+ }
+
+
+
+ @Override
+ public void deactivate() {
+ // for downwards compatibility (already running studies)
+ String newSurveyId = properties.getString(LIME_SURVEY_ID);
+ String activeSurveyId = sdk.getValue(LIME_SURVEY_ID, String.class).orElse(null);
+
+ if(activeSurveyId == null || activeSurveyId.equals(newSurveyId)) {
+ sdk.setValue(LIME_SURVEY_ID, newSurveyId);
+ }
}
public boolean writeDataPoints(String token, int surveyId, int savedId) {
diff --git a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java
index 9a752ef0..7aa842a1 100644
--- a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java
+++ b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java
@@ -27,7 +27,7 @@
public class LimeSurveyObservationFactory, P extends ObservationProperties>
extends ObservationFactory {
- private static List properties = List.of(
+ private static final List properties = List.of(
/* TODO enable Autocomplete in FE
new AutocompleteValue("limeSurveyId", "surveys")
.setName("Survey")
@@ -81,6 +81,12 @@ public LimeSurveyObservation create(MoreObservationSDK sd
return new LimeSurveyObservation(sdk, validate((P)properties), limeSurveyRequestService);
}
+ @Override
+ public ObservationProperties preImport(ObservationProperties properties) {
+ properties.remove(LimeSurveyObservation.LIME_SURVEY_ID);
+ return properties;
+ }
+
@Override
public JsonNode handleAPICall(String slug, User user, JsonNode input) throws ApiCallException {
String filter = Optional.ofNullable(input.get("filter")).map(JsonNode::asText).orElse(null);
diff --git a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyRequestService.java b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyRequestService.java
index 6417d6de..1a63b16f 100644
--- a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyRequestService.java
+++ b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyRequestService.java
@@ -138,15 +138,22 @@ private List createParticipants(Set participantIds, St
);
LOGGER.info("sent {} participants to lime", participantIds.size());
- List data = mapper.readValue(client.send(request, HttpResponse.BodyHandlers.ofString()).body(),
- LimeSurveyParticipantResponse.class)
- .result();
+ String rsp = client.send(request, HttpResponse.BodyHandlers.ofString()).body();
+
+ if(rsp.contains("Error: Invalid survey ID")) {
+ throw new RuntimeException("Invalid survey ID: " + surveyId);
+ }
+
+ List data = mapper.readValue(
+ rsp,
+ LimeSurveyParticipantResponse.class
+ ).result();
releaseSessionKey(sessionKey);
LOGGER.info("result: {}", data.stream().map(ParticipantData::toString).collect(Collectors.joining()));
return data;
- }catch(IOException | InterruptedException e){
+ } catch( IOException | InterruptedException e){
LOGGER.error("Error creating participants for survey {}", surveyId);
throw new RuntimeException(e);
}
diff --git a/studymanager-observation/src/test/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationTest.java b/studymanager-observation/src/test/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationTest.java
new file mode 100644
index 00000000..ff206f6e
--- /dev/null
+++ b/studymanager-observation/src/test/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationTest.java
@@ -0,0 +1,37 @@
+package io.redlink.more.studymanager.component.observation.lime;
+
+import io.redlink.more.studymanager.core.properties.ObservationProperties;
+import io.redlink.more.studymanager.core.sdk.MoreObservationSDK;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class LimeSurveyObservationTest {
+ @Test
+ public void testLimeSurveyIdValidation() {
+ MoreObservationSDK sdk = mock(MoreObservationSDK.class);
+ ObservationProperties properties = mock(ObservationProperties.class);
+
+ when(sdk.getValue(anyString(), any()))
+ .thenReturn(Optional.empty(), Optional.of("equals"), Optional.of("other"));
+ when(properties.getString(anyString())).thenReturn("valid", "equals", "different");
+
+ LimeSurveyObservation o = new LimeSurveyObservation(sdk, properties, null);
+ Assertions.assertEquals("valid", o.checkAndGetSurveyId());
+ Assertions.assertEquals("equals", o.checkAndGetSurveyId());
+ boolean expectedError = false;
+ try {
+ o.checkAndGetSurveyId();
+ } catch (RuntimeException e) {
+ expectedError = true;
+ }
+
+ Assertions.assertTrue(expectedError);
+ }
+}
diff --git a/studymanager/pom.xml b/studymanager/pom.xml
index d252c01a..021a90bc 100644
--- a/studymanager/pom.xml
+++ b/studymanager/pom.xml
@@ -84,7 +84,7 @@
org.flywaydb
- flyway-core
+ flyway-database-postgresql
org.postgresql
@@ -107,6 +107,11 @@
c3p0
+
+ net.sf.biweekly
+ biweekly
+
+
org.springframework.boot
spring-boot-configuration-processor
@@ -119,7 +124,7 @@
commons-io
commons-io
- 2.13.0
+ 2.16.1
org.apache.commons
@@ -237,33 +242,18 @@
io.redlink.more.studymanager.api.v1.webservices
io.redlink.more.studymanager.api.v1.model
-
-
-
- app-api
-
- generate
-
-
- ${project.basedir}/src/main/resources/openapi/AppAPI.yaml
-
- io.redlink.more.mmb.api.v1.app.webservices
- io.redlink.more.mmb.api.v1.app.model
-
-
-
- internal-api
-
- generate
-
-
-
- true
-
- ${project.basedir}/src/main/resources/openapi/ExternalAPI.yaml
-
- io.redlink.more.mmb.api.v1.external.webservices
- io.redlink.more.mmb.api.v1.external.model
+
+
+ string+time=LocalTime
+
+
+ Instant=java.time.Instant
+ LocalTime=java.time.LocalTime
+
+
+ Instant=java.time.Instant
+ LocalTime=java.time.LocalTime
+
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java
index 3b0d4414..5aa7bc21 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java
@@ -84,6 +84,7 @@ protected SecurityFilterChain filterChain(HttpSecurity http,
//TODO specific handling of temporary sidecar
.requestMatchers("/api/v1/components/observation/lime-survey-observation/end.html").permitAll()
.requestMatchers("/api/v1/studies/*/export/studydata/*").permitAll()
+ .requestMatchers("/api/v1/studies/*/calendar.ics").permitAll()
.requestMatchers("/api/v1/**").authenticated()
.requestMatchers("/kibana/**").authenticated()
.requestMatchers("/login/init").authenticated()
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java
new file mode 100644
index 00000000..2093ad09
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java
@@ -0,0 +1,48 @@
+package io.redlink.more.studymanager.controller.studymanager;
+
+import io.redlink.more.studymanager.api.v1.model.StudyTimelineDTO;
+import io.redlink.more.studymanager.api.v1.webservices.CalendarApi;
+import io.redlink.more.studymanager.controller.RequiresStudyRole;
+import io.redlink.more.studymanager.model.StudyRole;
+import io.redlink.more.studymanager.model.transformer.TimelineTransformer;
+import io.redlink.more.studymanager.properties.GatewayProperties;
+import io.redlink.more.studymanager.service.CalendarService;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE)
+@EnableConfigurationProperties(GatewayProperties.class)
+public class CalendarApiV1Controller implements CalendarApi {
+
+ private final CalendarService service;
+ private final GatewayProperties properties;
+
+ public CalendarApiV1Controller(CalendarService service, GatewayProperties properties) {
+ this.service = service;
+ this.properties = properties;
+ }
+
+ @Override
+ public ResponseEntity getStudyCalendar(Long studyId) {
+ return ResponseEntity
+ .status(301)
+ .header("Location", properties.getBaseUrl() + "/api/v1/calendar/studies/" + studyId + "/calendar.ics")
+ .build();
+ }
+
+ @Override
+ @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR})
+ public ResponseEntity getStudyTimeline(Long studyId, Integer participant, Integer studyGroup, OffsetDateTime referenceDate, LocalDate from, LocalDate to) {
+ return ResponseEntity.ok(
+ TimelineTransformer.toStudyTimelineDTO(
+ service.getTimeline(studyId, participant, studyGroup, referenceDate, from, to)
+ )
+ );
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationsApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationsApiV1Controller.java
index d7356212..545fdc6c 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationsApiV1Controller.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationsApiV1Controller.java
@@ -83,10 +83,10 @@ public ResponseEntity updateObservation(Long studyId, Integer ob
@Override
@RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR})
- public ResponseEntity createToken(Long studyId, Integer observationId, String tokenLabel) {
- if(tokenLabel.isBlank()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); }
+ public ResponseEntity createToken(Long studyId, Integer observationId, EndpointTokenDTO token) {
+ if(token.getTokenLabel().isBlank()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); }
- Optional addedToken = integrationService.addToken(studyId, observationId, tokenLabel.replace("\"", ""));
+ Optional addedToken = integrationService.addToken(studyId, observationId, token.getTokenLabel());
if(addedToken.isEmpty()) {
throw new BadRequestException("Token with given label already exists for given observation");
}
@@ -98,6 +98,16 @@ public ResponseEntity createToken(Long studyId, Integer observ
);
}
+ @Override
+ @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR})
+ public ResponseEntity updateTokenLabel(Long studyId, Integer observationId, Integer tokenId, EndpointTokenDTO endpointToken) {
+ Optional token = integrationService.updateToken(studyId, observationId, tokenId, endpointToken.getTokenLabel());
+
+ return ResponseEntity.of(
+ token.map(EndpointTokenTransformer::toEndpointTokenDTO)
+ );
+ }
+
@Override
@RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR})
public ResponseEntity getToken(Long studyId, Integer observationId, Integer tokenId) {
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/StudyApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/StudyApiV1Controller.java
index 6671cbb1..74e0edf4 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/StudyApiV1Controller.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/StudyApiV1Controller.java
@@ -12,7 +12,6 @@
import io.redlink.more.studymanager.api.v1.model.StudyDTO;
import io.redlink.more.studymanager.api.v1.webservices.StudiesApi;
import io.redlink.more.studymanager.controller.RequiresStudyRole;
-import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.model.StudyRole;
import io.redlink.more.studymanager.model.transformer.StudyTransformer;
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/exception/BadRequestException.java b/studymanager/src/main/java/io/redlink/more/studymanager/exception/BadRequestException.java
index 0a951b02..2fce12e0 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/exception/BadRequestException.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/exception/BadRequestException.java
@@ -14,10 +14,15 @@
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
+
public BadRequestException(String cause) {
super(cause);
}
+ public BadRequestException(String cause, Throwable throwable) {
+ super(String.format("%s: %s", cause, throwable.getMessage()), throwable);
+ }
+
public BadRequestException(Throwable cause) {
super(cause);
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java b/studymanager/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java
index f782323d..447805c8 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java
@@ -29,6 +29,10 @@ public static NotFoundException StudyGroup(long studyId, int studyGroupId) {
return new NotFoundException("StudyGroup", studyId + "/" + studyGroupId);
}
+ public static NotFoundException Participant(long studyId, int participantId) {
+ return new NotFoundException("Participant", studyId + "/" + participantId);
+ }
+
public static NotFoundException ObservationFactory(String type) {
return new NotFoundException("Observation Factory '" + type + "'");
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/IntegrationInfo.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/IntegrationInfo.java
new file mode 100644
index 00000000..10964a8d
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/IntegrationInfo.java
@@ -0,0 +1,6 @@
+package io.redlink.more.studymanager.model;
+
+public record IntegrationInfo(
+ String name,
+ Integer observationId
+) {}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/Intervention.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/Intervention.java
index 08af2ec5..9517ac7e 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/Intervention.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/Intervention.java
@@ -8,6 +8,9 @@
*/
package io.redlink.more.studymanager.model;
+import io.redlink.more.studymanager.model.scheduler.Event;
+import io.redlink.more.studymanager.model.scheduler.ScheduleEvent;
+
import java.time.Instant;
public class Intervention {
@@ -16,7 +19,7 @@ public class Intervention {
private String title;
private String purpose;
private Integer studyGroupId;
- private Event schedule;
+ private ScheduleEvent schedule;
private Instant created;
private Instant modified;
@@ -65,11 +68,11 @@ public Intervention setStudyGroupId(Integer studyGroupId) {
return this;
}
- public Event getSchedule() {
+ public ScheduleEvent getSchedule() {
return schedule;
}
- public Intervention setSchedule(Event schedule) {
+ public Intervention setSchedule(ScheduleEvent schedule) {
this.schedule = schedule;
return this;
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/Observation.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/Observation.java
index 547146ad..fdcbc68a 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/Observation.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/Observation.java
@@ -9,6 +9,7 @@
package io.redlink.more.studymanager.model;
import io.redlink.more.studymanager.core.properties.ObservationProperties;
+import io.redlink.more.studymanager.model.scheduler.ScheduleEvent;
import java.time.Instant;
@@ -21,7 +22,7 @@ public class Observation {
private String type;
private Integer studyGroupId;
private ObservationProperties properties;
- private Event schedule;
+ private ScheduleEvent schedule;
private Instant created;
private Instant modified;
private Boolean hidden;
@@ -99,11 +100,11 @@ public Observation setProperties(ObservationProperties properties) {
return this;
}
- public Event getSchedule() {
+ public ScheduleEvent getSchedule() {
return schedule;
}
- public Observation setSchedule(Event schedule) {
+ public Observation setSchedule(ScheduleEvent schedule) {
this.schedule = schedule;
return this;
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/Participant.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/Participant.java
index a835a514..3f1b359f 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/Participant.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/Participant.java
@@ -17,6 +17,7 @@ public class Participant {
private Status status;
private Instant created;
private Instant modified;
+ private Instant start;
private String registrationToken;
@@ -51,6 +52,15 @@ public Participant setStatus(Status status) {
return this;
}
+ public Participant setStart( Instant start ) {
+ this.start = start;
+ return this;
+ }
+
+ public Instant getStart() {
+ return start;
+ }
+
public Participant setStudyId(Long studyId) {
this.studyId = studyId;
return this;
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/Study.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/Study.java
index f3d112f3..a1fe0447 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/Study.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/Study.java
@@ -8,6 +8,8 @@
*/
package io.redlink.more.studymanager.model;
+import io.redlink.more.studymanager.model.scheduler.Duration;
+
import java.time.Instant;
import java.time.LocalDate;
import java.util.Set;
@@ -19,6 +21,7 @@ public class Study {
private String participantInfo;
private String consentInfo;
private String finishText;
+ private Duration duration;
private Status studyState;
private LocalDate startDate;
private LocalDate endDate;
@@ -33,7 +36,10 @@ public enum Status {
DRAFT("draft"),
ACTIVE("active"),
PAUSED("paused"),
- CLOSED("closed");
+ CLOSED("closed"),
+ PREVIEW("preview"),
+ PAUSED_PREVIEW("paused-preview"),
+ ;
private final String value;
@@ -44,6 +50,17 @@ public enum Status {
public String getValue() {
return value;
}
+
+ public static Status fromValue(String value) {
+ for (Status c : Status.values()) {
+ if (c.value.equalsIgnoreCase(value)) {
+ return c;
+ }
+ }
+ throw new IllegalArgumentException(
+ "No enum constant " + Status.class.getCanonicalName() + " with value " + value
+ );
+ }
}
public Long getStudyId() {
@@ -100,6 +117,15 @@ public Study setFinishText(String finishText) {
return this;
}
+ public Duration getDuration() {
+ return duration;
+ }
+
+ public Study setDuration(Duration duration) {
+ this.duration = duration;
+ return this;
+ }
+
public Status getStudyState() {
return studyState;
}
@@ -192,6 +218,7 @@ public String toString() {
", endDate=" + endDate +
", plannedStartDate=" + plannedStartDate +
", plannedEndDate=" + plannedEndDate +
+ ", duration=" + duration +
", created=" + created +
", modified=" + modified +
", institute=" + contact.getInstitute() +
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyGroup.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyGroup.java
index a1613297..63a66d17 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyGroup.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyGroup.java
@@ -8,6 +8,8 @@
*/
package io.redlink.more.studymanager.model;
+import io.redlink.more.studymanager.model.scheduler.Duration;
+
import java.time.Instant;
public class StudyGroup {
@@ -15,6 +17,7 @@ public class StudyGroup {
private Integer studyGroupId;
private String title;
private String purpose;
+ private Duration duration;
private Instant created;
private Instant modified;
@@ -54,6 +57,15 @@ public StudyGroup setPurpose(String purpose) {
return this;
}
+ public Duration getDuration() {
+ return duration;
+ }
+
+ public StudyGroup setDuration(Duration duration) {
+ this.duration = duration;
+ return this;
+ }
+
public Instant getCreated() {
return created;
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java
index 24ee6bdb..656f0ac4 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java
@@ -17,8 +17,10 @@ public class StudyImportExport {
private List studyGroups;
private List observations;
private List interventions;
+ private List participants;
private Map triggers;
private Map> actions;
+ private List integrations;
public Study getStudy() {
return study;
@@ -73,4 +75,26 @@ public StudyImportExport setActions(Map> actions) {
this.actions = actions;
return this;
}
+
+ public List getParticipants() {
+ return participants;
+ }
+
+ public StudyImportExport setParticipants(List participants) {
+ this.participants = participants;
+ return this;
+ }
+
+ public List getIntegrations() {
+ return integrations;
+ }
+
+ public StudyImportExport setIntegrations(List integrations) {
+ this.integrations = integrations;
+ return this;
+ }
+
+ public record ParticipantInfo(
+ Integer groupId
+ ) {}
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/Duration.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/Duration.java
new file mode 100644
index 00000000..9cd086c7
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/Duration.java
@@ -0,0 +1,184 @@
+package io.redlink.more.studymanager.model.scheduler;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.redlink.more.studymanager.api.v1.model.DurationDTO;
+import io.redlink.more.studymanager.api.v1.model.StudyDurationDTO;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+import java.util.Comparator;
+
+public class Duration {
+
+ private Integer value;
+
+ public java.time.Duration asDuration() {
+ return java.time.Duration.of(value, unit.toChronoUnit());
+ }
+
+ /**
+ * unit of time to offset
+ */
+ public enum Unit {
+ MINUTE("MINUTE", ChronoUnit.MINUTES),
+
+ HOUR("HOUR", ChronoUnit.HOURS),
+
+ DAY("DAY", ChronoUnit.DAYS);
+
+ private String value;
+
+ @JsonIgnore
+ private ChronoUnit chronoUnit;
+
+ Unit(String value, ChronoUnit chronoValue) {
+ this.value = value;
+ this.chronoUnit = chronoValue;
+ }
+
+
+ public String getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return String.valueOf(value);
+ }
+
+ @JsonCreator
+ public static Unit fromValue(String value) {
+ for (Unit b : Unit.values()) {
+ if (b.value.equals(value)) {
+ return b;
+ }
+ }
+ throw new IllegalArgumentException("Unexpected value '" + value + "'");
+ }
+
+ public ChronoUnit toChronoUnit() {
+ return chronoUnit;
+ }
+
+ public static Unit fromDurationDTOUnit(DurationDTO.UnitEnum unit) {
+ switch (unit) {
+ case MINUTE:
+ return MINUTE;
+ case HOUR:
+ return HOUR;
+ case DAY:
+ return DAY;
+ default:
+ throw new IllegalArgumentException("Unexpected value '" + unit + "'");
+ }
+ }
+
+ public static DurationDTO.UnitEnum toDurationDTOUnit(Unit unit) {
+ switch (unit) {
+ case MINUTE:
+ return DurationDTO.UnitEnum.MINUTE;
+ case HOUR:
+ return DurationDTO.UnitEnum.HOUR;
+ case DAY:
+ return DurationDTO.UnitEnum.DAY;
+ default:
+ throw new IllegalArgumentException("Unexpected value '" + unit + "'");
+ }
+ }
+
+ public static Unit fromStudyDurationDTOUnit(StudyDurationDTO.UnitEnum unit) {
+ switch (unit) {
+ case MINUTE:
+ return MINUTE;
+ case HOUR:
+ return HOUR;
+ case DAY:
+ return DAY;
+ default:
+ throw new IllegalArgumentException("Unexpected value '" + unit + "'");
+ }
+ }
+
+ public static StudyDurationDTO.UnitEnum toStudyDurationDTOUnit(Unit unit) {
+ switch (unit) {
+ case MINUTE:
+ return StudyDurationDTO.UnitEnum.MINUTE;
+ case HOUR:
+ return StudyDurationDTO.UnitEnum.HOUR;
+ case DAY:
+ return StudyDurationDTO.UnitEnum.DAY;
+ default:
+ throw new IllegalArgumentException("Unexpected value '" + unit + "'");
+ }
+ }
+ }
+
+ private Unit unit;
+
+ public Duration() {
+ }
+
+ public Integer getValue() {
+ return value;
+ }
+
+ public Duration setValue(Integer value) {
+ this.value = value;
+ return this;
+ }
+
+ public Unit getUnit() {
+ return unit;
+ }
+
+ public Duration setUnit(Unit unit) {
+ this.unit = unit;
+ return this;
+ }
+
+ public static StudyDurationDTO toStudyDurationDTO(Duration duration) {
+ if (duration != null)
+ return new StudyDurationDTO()
+ .value(duration.getValue())
+ .unit(Unit.toStudyDurationDTOUnit(duration.unit));
+ else return null;
+ }
+
+ public static Duration fromStudyDurationDTO(StudyDurationDTO dto) {
+ if (dto != null)
+ return new Duration()
+ .setValue(dto.getValue())
+ .setUnit(Unit.fromStudyDurationDTOUnit(dto.getUnit()));
+ else return null;
+ }
+
+ public static DurationDTO toDurationDTO(Duration duration) {
+ if (duration != null)
+ return new DurationDTO()
+ .value(duration.getValue())
+ .unit(Unit.toDurationDTOUnit(duration.unit));
+ else return null;
+ }
+
+ public static Duration fromDurationDTO(DurationDTO dto) {
+ if (dto != null)
+ return new Duration()
+ .setValue(dto.getValue())
+ .setUnit(Unit.fromDurationDTOUnit(dto.getUnit()));
+ else return null;
+ }
+
+ @Override
+ public String toString() {
+ return "Duration{" +
+ "offset=" + value +
+ ", unit=" + unit +
+ '}';
+ }
+
+ public static final Comparator DURATION_COMPARATOR =
+ Comparator.comparing(d -> java.time.Duration.of(d.value, d.unit.chronoUnit));
+
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/Event.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/Event.java
similarity index 61%
rename from studymanager/src/main/java/io/redlink/more/studymanager/model/Event.java
rename to studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/Event.java
index 28c46058..2f09efdf 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/Event.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/Event.java
@@ -1,20 +1,24 @@
-/*
- * 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;
+package io.redlink.more.studymanager.model.scheduler;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.Instant;
-public class Event {
+public class Event implements ScheduleEvent {
+ public static final String TYPE = "Event";
+ private String type;
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
private Instant dateStart;
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
private Instant dateEnd;
private RecurrenceRule recurrenceRule;
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
public Instant getDateStart() {
return dateStart;
}
@@ -41,4 +45,6 @@ public Event setRRule(RecurrenceRule recurrenceRule) {
this.recurrenceRule = recurrenceRule;
return this;
}
+
+
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/RecurrenceRule.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RecurrenceRule.java
similarity index 80%
rename from studymanager/src/main/java/io/redlink/more/studymanager/model/RecurrenceRule.java
rename to studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RecurrenceRule.java
index 13e5a00b..9459a9dd 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/RecurrenceRule.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RecurrenceRule.java
@@ -1,18 +1,13 @@
-/*
- * 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;
+package io.redlink.more.studymanager.model.scheduler;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.Instant;
import java.util.List;
public class RecurrenceRule {
private String freq;
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
private Instant until;
private Integer count;
private Integer interval;
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeDate.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeDate.java
new file mode 100644
index 00000000..071beb0c
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeDate.java
@@ -0,0 +1,51 @@
+package io.redlink.more.studymanager.model.scheduler;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
+import java.time.LocalTime;
+
+public class RelativeDate {
+
+ private Duration offset;
+ @JsonSerialize(using = LocalTimeSerializer.class)
+ @JsonDeserialize(using = LocalTimeDeserializer.class)
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
+ private LocalTime time;
+
+ public RelativeDate() {
+ }
+
+ @JsonIgnore
+ public int getHours() {
+ return time.getHour();
+ }
+
+ @JsonIgnore
+ public int getMinutes() {
+ return time.getMinute();
+ }
+
+
+ public Duration getOffset() {
+ return offset;
+ }
+
+ public RelativeDate setOffset(Duration offset) {
+ this.offset = offset;
+ return this;
+ }
+
+ public LocalTime getTime() {
+ return time;
+ }
+
+ public RelativeDate setTime(LocalTime time) {
+ this.time = time;
+ return this;
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeEvent.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeEvent.java
new file mode 100644
index 00000000..7fe2a688
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeEvent.java
@@ -0,0 +1,50 @@
+package io.redlink.more.studymanager.model.scheduler;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RelativeEvent implements ScheduleEvent {
+
+ public static final String TYPE = "RelativeEvent";
+
+ private RelativeDate dtstart;
+
+ private RelativeDate dtend;
+
+ private RelativeRecurrenceRule rrrule;
+
+ public RelativeEvent() {
+ }
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ public RelativeDate getDtstart() {
+ return dtstart;
+ }
+
+ public RelativeEvent setDtstart(RelativeDate dtstart) {
+ this.dtstart = dtstart;
+ return this;
+ }
+
+ public RelativeDate getDtend() {
+ return dtend;
+ }
+
+ public RelativeEvent setDtend(RelativeDate dtend) {
+ this.dtend = dtend;
+ return this;
+ }
+
+ public RelativeRecurrenceRule getRrrule() {
+ return rrrule;
+ }
+
+ public RelativeEvent setRrrule(RelativeRecurrenceRule rrrule) {
+ this.rrrule = rrrule;
+ return this;
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeRecurrenceRule.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeRecurrenceRule.java
new file mode 100644
index 00000000..abfaf462
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/RelativeRecurrenceRule.java
@@ -0,0 +1,29 @@
+package io.redlink.more.studymanager.model.scheduler;
+
+public class RelativeRecurrenceRule {
+
+ private Duration frequency;
+
+ private Duration endAfter;
+
+ public RelativeRecurrenceRule() {
+ }
+
+ public Duration getFrequency() {
+ return frequency;
+ }
+
+ public RelativeRecurrenceRule setFrequency(Duration frequency) {
+ this.frequency = frequency;
+ return this;
+ }
+
+ public Duration getEndAfter() {
+ return endAfter;
+ }
+
+ public RelativeRecurrenceRule setEndAfter(Duration endAfter) {
+ this.endAfter = endAfter;
+ return this;
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/ScheduleEvent.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/ScheduleEvent.java
new file mode 100644
index 00000000..079867d0
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/scheduler/ScheduleEvent.java
@@ -0,0 +1,18 @@
+package io.redlink.more.studymanager.model.scheduler;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+@JsonIgnoreProperties(
+ value = "type", // ignore manually set type, it will be automatically generated by Jackson during serialization
+ allowSetters = true // allows the type to be set during deserialization
+)
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true, defaultImpl = Event.class)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = Event.class, name = Event.TYPE),
+ @JsonSubTypes.Type(value = RelativeEvent.class, name = RelativeEvent.TYPE)
+})
+public interface ScheduleEvent {
+ public String getType();
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/InterventionTimelineEvent.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/InterventionTimelineEvent.java
new file mode 100644
index 00000000..bb98ffb1
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/InterventionTimelineEvent.java
@@ -0,0 +1,26 @@
+package io.redlink.more.studymanager.model.timeline;
+
+import io.redlink.more.studymanager.model.Intervention;
+import io.redlink.more.studymanager.model.Trigger;
+
+import java.time.Instant;
+
+public record InterventionTimelineEvent(
+ Integer interventionId,
+ Integer studyGroupId,
+ String title,
+ String purpose,
+ Instant start,
+ String scheduleType
+) {
+ public static InterventionTimelineEvent fromInterventionAndTrigger(Intervention intervention, Trigger trigger, Instant start) {
+ return new InterventionTimelineEvent(
+ intervention.getInterventionId(),
+ intervention.getStudyGroupId(),
+ intervention.getTitle(),
+ intervention.getPurpose(),
+ start,
+ trigger.getType()
+ );
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/ObservationTimelineEvent.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/ObservationTimelineEvent.java
new file mode 100644
index 00000000..fa633ae2
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/ObservationTimelineEvent.java
@@ -0,0 +1,31 @@
+package io.redlink.more.studymanager.model.timeline;
+
+import io.redlink.more.studymanager.model.Observation;
+
+import java.time.Instant;
+
+public record ObservationTimelineEvent(
+ Integer observationId,
+ Integer studyGroupId,
+ String title,
+ String purpose,
+ String type,
+ Instant start,
+ Instant end,
+ Boolean hidden,
+ String scheduleType
+) {
+ public static ObservationTimelineEvent fromObservation(Observation observation, Instant start, Instant end) {
+ return new ObservationTimelineEvent(
+ observation.getObservationId(),
+ observation.getStudyGroupId(),
+ observation.getTitle(),
+ observation.getPurpose(),
+ observation.getType(),
+ start,
+ end,
+ observation.getHidden(),
+ observation.getSchedule().getType()
+ );
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/StudyTimeline.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/StudyTimeline.java
new file mode 100644
index 00000000..f1392c7d
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/StudyTimeline.java
@@ -0,0 +1,14 @@
+package io.redlink.more.studymanager.model.timeline;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.List;
+import org.apache.commons.lang3.Range;
+
+public record StudyTimeline(
+ Instant signup,
+ Range participationRange,
+ List observationTimelineEvents,
+ List interventionTimelineEvents
+) {
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/TimelineFilter.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/TimelineFilter.java
new file mode 100644
index 00000000..be22d630
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/timeline/TimelineFilter.java
@@ -0,0 +1,6 @@
+package io.redlink.more.studymanager.model.timeline;
+
+public record TimelineFilter (
+ Integer studyGroupId,
+ Integer participantId
+) {}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/EventTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/EventTransformer.java
index 3e931166..e653a0b3 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/EventTransformer.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/EventTransformer.java
@@ -8,33 +8,63 @@
*/
package io.redlink.more.studymanager.model.transformer;
-import io.redlink.more.studymanager.api.v1.model.EventDTO;
-import io.redlink.more.studymanager.api.v1.model.FrequencyDTO;
-import io.redlink.more.studymanager.api.v1.model.RecurrenceRuleDTO;
-import io.redlink.more.studymanager.api.v1.model.WeekdayDTO;
-import io.redlink.more.studymanager.model.Event;
-import io.redlink.more.studymanager.model.RecurrenceRule;
+import io.redlink.more.studymanager.api.v1.model.*;
+import io.redlink.more.studymanager.model.scheduler.*;
public final class EventTransformer {
private EventTransformer() {
}
- public static Event fromEventDTO_V1(EventDTO dto) {
- if (dto != null)
- return new Event()
- .setDateStart(Transformers.toInstant(dto.getDtstart()))
- .setDateEnd(Transformers.toInstant(dto.getDtend()))
- .setRRule(fromRecurrenceRuleDTO(dto.getRrule()));
+ public static ScheduleEvent fromObservationScheduleDTO_V1(ObservationScheduleDTO genericDto) {
+ if (genericDto != null) {
+ if(genericDto.getType() == null || Event.TYPE.equals(genericDto.getType())) {
+ EventDTO dto = (EventDTO) genericDto;
+ return new Event()
+ .setDateStart(Transformers.toInstant(dto.getDtstart()))
+ .setDateEnd(Transformers.toInstant(dto.getDtend()))
+ .setRRule(fromRecurrenceRuleDTO(dto.getRrule()));
+ } else if(RelativeEvent.TYPE.equals(genericDto.getType())) {
+ RelativeEventDTO dto = (RelativeEventDTO) genericDto;
+ return new RelativeEvent()
+ .setDtstart(new RelativeDate()
+ .setOffset(fromDurationDTO(dto.getDtstart().getOffset()))
+ .setTime(dto.getDtstart().getTime()))
+ .setDtend(new RelativeDate()
+ .setOffset(fromDurationDTO(dto.getDtend().getOffset()))
+ .setTime(dto.getDtend().getTime()))
+ .setRrrule(fromRelativeRecurrenceRuleDTO(dto.getRrrule()));
+
+ } else {
+ throw new IllegalArgumentException("Unknown Event Type: " + genericDto.getType());
+ }
+ }
else return null;
}
- public static EventDTO toEventDTO_V1(Event event) {
+ public static ObservationScheduleDTO toObservationScheduleDTO_V1(ScheduleEvent event) {
if (event != null)
- return new EventDTO()
- .dtstart(Transformers.toOffsetDateTime(event.getDateStart()))
- .dtend(Transformers.toOffsetDateTime(event.getDateEnd()))
- .rrule(toRecurrenceRuleDTO(event.getRRule()));
+ if(event.getType() == null || Event.TYPE.equals(event.getType())) {
+ Event e = (Event) event;
+ return new EventDTO()
+ .type(Event.TYPE)
+ .dtstart(Transformers.toOffsetDateTime(e.getDateStart()))
+ .dtend(Transformers.toOffsetDateTime(e.getDateEnd()))
+ .rrule(toRecurrenceRuleDTO(e.getRRule()));
+ } else if(RelativeEvent.TYPE.equals(event.getType())) {
+ RelativeEvent e = (RelativeEvent) event;
+ return new RelativeEventDTO()
+ .type(RelativeEvent.TYPE)
+ .dtstart(new RelativeDateDTO()
+ .offset(toDurationDTO(e.getDtstart().getOffset()))
+ .time(e.getDtstart().getTime()))
+ .dtend(new RelativeDateDTO()
+ .offset(toDurationDTO(e.getDtend().getOffset()))
+ .time(e.getDtend().getTime()))
+ .rrrule(toRelativeRecurrenceRuleDTO(e.getRrrule()));
+ } else {
+ throw new IllegalArgumentException("Unknown Event Type: " + event.getType());
+ }
else return null;
}
@@ -65,4 +95,36 @@ private static RecurrenceRuleDTO toRecurrenceRuleDTO(RecurrenceRule recurrenceRu
.bysetpos(recurrenceRule.getBySetPos());
else return null;
}
+
+ private static RelativeRecurrenceRuleDTO toRelativeRecurrenceRuleDTO(RelativeRecurrenceRule relativeRecurrenceRule) {
+ if (relativeRecurrenceRule != null)
+ return new RelativeRecurrenceRuleDTO()
+ .frequency(toDurationDTO(relativeRecurrenceRule.getFrequency()))
+ .endAfter(toDurationDTO(relativeRecurrenceRule.getEndAfter()));
+ else return null;
+ }
+
+ private static RelativeRecurrenceRule fromRelativeRecurrenceRuleDTO(RelativeRecurrenceRuleDTO dto) {
+ if (dto != null)
+ return new RelativeRecurrenceRule()
+ .setFrequency(fromDurationDTO(dto.getFrequency()))
+ .setEndAfter(fromDurationDTO(dto.getEndAfter()));
+ else return null;
+ }
+
+ private static Duration fromDurationDTO(DurationDTO dto) {
+ if (dto != null)
+ return new Duration()
+ .setValue(dto.getValue())
+ .setUnit(dto.getUnit() != null ? Duration.Unit.fromDurationDTOUnit(dto.getUnit()) : null);
+ else return null;
+ }
+
+ private static DurationDTO toDurationDTO(Duration duration) {
+ if (duration != null)
+ return new DurationDTO()
+ .value(duration.getValue())
+ .unit(duration.getUnit() != null ? Duration.Unit.toDurationDTOUnit(duration.getUnit()) : null);
+ else return null;
+ }
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java
index 3d0bf584..c166abfd 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java
@@ -8,14 +8,14 @@
*/
package io.redlink.more.studymanager.model.transformer;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import io.redlink.more.studymanager.api.v1.model.IntegrationInfoDTO;
import io.redlink.more.studymanager.api.v1.model.InterventionDTO;
+import io.redlink.more.studymanager.api.v1.model.ParticipantInfoDTO;
import io.redlink.more.studymanager.api.v1.model.StudyImportExportDTO;
+import io.redlink.more.studymanager.model.IntegrationInfo;
import io.redlink.more.studymanager.model.StudyImportExport;
-import io.redlink.more.studymanager.utils.MapperUtils;
-import java.util.Map;
+import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -26,41 +26,71 @@ private ImportExportTransformer() {}
public static StudyImportExport fromStudyImportExportDTO_V1(StudyImportExportDTO dto) {
return new StudyImportExport()
.setStudy(StudyTransformer.fromStudyDTO_V1(dto.getStudy()))
- .setStudyGroups(dto.getStudyGroups().stream().map(
- StudyGroupTransformer::fromStudyGroupDTO_V1
- ).toList())
- .setObservations(dto.getObservations().stream().map(
- ObservationTransformer::fromObservationDTO_V1
- ).toList())
- .setInterventions(dto.getInterventions().stream().map(
- InterventionTransformer::fromInterventionDTO_V1
- ).toList())
+ .setStudyGroups(transform(dto.getStudyGroups(), StudyGroupTransformer::fromStudyGroupDTO_V1))
+ .setObservations(transform(dto.getObservations(), ObservationTransformer::fromObservationDTO_V1))
+ .setInterventions(transform(dto.getInterventions(), InterventionTransformer::fromInterventionDTO_V1))
.setTriggers(
dto.getInterventions().stream().collect(Collectors.toMap(
- InterventionDTO::getInterventionId, interventionDTO ->
- TriggerTransformer.fromTriggerDTO_V1(interventionDTO.getTrigger()))))
+ InterventionDTO::getInterventionId,
+ interventionDTO ->
+ TriggerTransformer.fromTriggerDTO_V1(interventionDTO.getTrigger())
+ ))
+ )
.setActions(
dto.getInterventions().stream().collect(Collectors.toMap(
- InterventionDTO::getInterventionId, interventionDTO ->
- interventionDTO.getActions().stream().map(ActionTransformer::fromActionDTO_V1).toList()))
- );
+ InterventionDTO::getInterventionId,
+ interventionDTO ->
+ transform(interventionDTO.getActions(), ActionTransformer::fromActionDTO_V1)
+ ))
+ )
+ .setParticipants(transform(dto.getParticipants(), ImportExportTransformer::fromParticipantDTO_V1))
+ .setIntegrations(transform(dto.getIntegrations(), ImportExportTransformer::fromIntegrationExportDTO_V1));
}
public static StudyImportExportDTO toStudyImportExportDTO_V1(StudyImportExport studyImportExport) {
return new StudyImportExportDTO()
.study(StudyTransformer.toStudyDTO_V1(studyImportExport.getStudy()))
- .studyGroups(studyImportExport.getStudyGroups().stream().map(
- StudyGroupTransformer::toStudyGroupDTO_V1).toList())
- .observations(studyImportExport.getObservations().stream().map(
- ObservationTransformer::toObservationDTO_V1).toList())
- .interventions(studyImportExport.getInterventions().stream().map( intervention ->
+ .studyGroups(transform(studyImportExport.getStudyGroups(), StudyGroupTransformer::toStudyGroupDTO_V1))
+ .observations(transform(studyImportExport.getObservations(), ObservationTransformer::toObservationDTO_V1))
+ .interventions(transform(studyImportExport.getInterventions(), intervention ->
InterventionTransformer.toInterventionDTO_V1(intervention)
.trigger(
TriggerTransformer.toTriggerDTO_V1(
studyImportExport.getTriggers().get(intervention.getInterventionId())
)
)
- .actions(studyImportExport.getActions().get(intervention.getInterventionId())
- .stream().map(ActionTransformer::toActionDTO_V1).toList())).toList());
+ .actions(
+ transform(
+ studyImportExport.getActions().get(intervention.getInterventionId()),
+ ActionTransformer::toActionDTO_V1
+ )
+ )
+ ))
+ .participants(transform(studyImportExport.getParticipants(), ImportExportTransformer::toParticipantDTO_V1))
+ .integrations(transform(studyImportExport.getIntegrations(), ImportExportTransformer::toIntegrationInfoDTO_V1));
+ }
+
+ private static ParticipantInfoDTO toParticipantDTO_V1(StudyImportExport.ParticipantInfo participant) {
+ return new ParticipantInfoDTO()
+ .studyGroup(participant.groupId());
+ }
+
+ private static StudyImportExport.ParticipantInfo fromParticipantDTO_V1(ParticipantInfoDTO participant) {
+ return new StudyImportExport.ParticipantInfo(participant.getStudyGroup());
+ }
+
+ private static List transform(List list, Function transformer) {
+ if (list == null) { return List.of(); }
+ return list.stream().map(transformer).toList();
+ }
+
+ private static IntegrationInfoDTO toIntegrationInfoDTO_V1(IntegrationInfo integration) {
+ return new IntegrationInfoDTO()
+ .name(integration.name())
+ .observationId(integration.observationId());
+ }
+
+ private static IntegrationInfo fromIntegrationExportDTO_V1(IntegrationInfoDTO integration) {
+ return new IntegrationInfo(integration.getName(), integration.getObservationId());
}
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java
index babc7f97..ef1e726f 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java
@@ -23,7 +23,7 @@ public static Intervention fromInterventionDTO_V1(InterventionDTO dto) {
.setTitle(dto.getTitle())
.setPurpose(dto.getPurpose())
.setStudyGroupId(dto.getStudyGroupId())
- .setSchedule(EventTransformer.fromEventDTO_V1(dto.getSchedule()));
+ .setSchedule(EventTransformer.fromObservationScheduleDTO_V1(dto.getSchedule()));
}
public static InterventionDTO toInterventionDTO_V1(Intervention intervention) {
@@ -33,7 +33,7 @@ public static InterventionDTO toInterventionDTO_V1(Intervention intervention) {
.title(intervention.getTitle())
.purpose(intervention.getPurpose())
.studyGroupId(intervention.getStudyGroupId())
- .schedule(EventTransformer.toEventDTO_V1(intervention.getSchedule()))
+ .schedule(EventTransformer.toObservationScheduleDTO_V1(intervention.getSchedule()))
.created(Transformers.toOffsetDateTime(intervention.getCreated()))
.modified(Transformers.toOffsetDateTime(intervention.getModified()));
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java
index e257deed..077ac44f 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java
@@ -28,7 +28,7 @@ public static Observation fromObservationDTO_V1(ObservationDTO dto) {
.setType(dto.getType())
.setStudyGroupId(dto.getStudyGroupId())
.setProperties(MapperUtils.MAPPER.convertValue(dto.getProperties(), ObservationProperties.class))
- .setSchedule(EventTransformer.fromEventDTO_V1(dto.getSchedule()))
+ .setSchedule(EventTransformer.fromObservationScheduleDTO_V1(dto.getSchedule()))
.setHidden(dto.getHidden())
.setNoSchedule(dto.getNoSchedule());
}
@@ -43,7 +43,7 @@ public static ObservationDTO toObservationDTO_V1(Observation observation) {
.type(observation.getType())
.studyGroupId(observation.getStudyGroupId())
.properties(observation.getProperties())
- .schedule(EventTransformer.toEventDTO_V1(observation.getSchedule()))
+ .schedule(EventTransformer.toObservationScheduleDTO_V1(observation.getSchedule()))
.created(Transformers.toOffsetDateTime(observation.getCreated()))
.modified(Transformers.toOffsetDateTime(observation.getModified()))
.hidden(observation.getHidden())
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java
index 42b7728b..098774e0 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java
@@ -34,6 +34,7 @@ public static ParticipantDTO toParticipantDTO_V1(Participant participant) {
.studyGroupId(participant.getStudyGroupId())
.registrationToken(participant.getRegistrationToken())
.status(ParticipantStatusDTO.fromValue(participant.getStatus().getValue()))
+ .start(Transformers.toOffsetDateTime(participant.getStart()))
.modified(Transformers.toOffsetDateTime(participant.getModified()))
.created(Transformers.toOffsetDateTime(participant.getCreated()));
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyGroupTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyGroupTransformer.java
index 4b2ac51b..7ad8a436 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyGroupTransformer.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyGroupTransformer.java
@@ -10,6 +10,7 @@
import io.redlink.more.studymanager.api.v1.model.StudyGroupDTO;
import io.redlink.more.studymanager.model.StudyGroup;
+import io.redlink.more.studymanager.model.scheduler.Duration;
public final class StudyGroupTransformer {
@@ -21,7 +22,8 @@ public static StudyGroup fromStudyGroupDTO_V1(StudyGroupDTO studyGroupDTO) {
.setStudyId(studyGroupDTO.getStudyId())
.setStudyGroupId(studyGroupDTO.getStudyGroupId())
.setTitle(studyGroupDTO.getTitle())
- .setPurpose(studyGroupDTO.getPurpose());
+ .setPurpose(studyGroupDTO.getPurpose())
+ .setDuration(Duration.fromStudyDurationDTO(studyGroupDTO.getDuration()));
}
public static StudyGroupDTO toStudyGroupDTO_V1(StudyGroup studyGroup) {
@@ -30,6 +32,7 @@ public static StudyGroupDTO toStudyGroupDTO_V1(StudyGroup studyGroup) {
.studyGroupId(studyGroup.getStudyGroupId())
.title(studyGroup.getTitle())
.purpose(studyGroup.getPurpose())
+ .duration(Duration.toStudyDurationDTO(studyGroup.getDuration()))
.created(Transformers.toOffsetDateTime(studyGroup.getCreated()))
.modified(Transformers.toOffsetDateTime(studyGroup.getModified()));
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyTransformer.java
index bbb55edd..24ac7edd 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyTransformer.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/StudyTransformer.java
@@ -14,6 +14,7 @@
import io.redlink.more.studymanager.api.v1.model.StudyStatusDTO;
import io.redlink.more.studymanager.model.Contact;
import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.scheduler.Duration;
public class StudyTransformer {
@@ -30,6 +31,7 @@ public static Study fromStudyDTO_V1(StudyDTO studyDTO) {
.setPurpose(studyDTO.getPurpose())
.setFinishText(studyDTO.getFinishText())
.setParticipantInfo(studyDTO.getParticipantInfo())
+ .setDuration(Duration.fromStudyDurationDTO(studyDTO.getDuration()))
.setConsentInfo(studyDTO.getConsentInfo())
.setPlannedStartDate(studyDTO.getPlannedStart())
.setPlannedEndDate(studyDTO.getPlannedEnd())
@@ -45,6 +47,7 @@ public static StudyDTO toStudyDTO_V1(Study study) {
.purpose(study.getPurpose())
.finishText(study.getFinishText())
.participantInfo(study.getParticipantInfo())
+ .duration(Duration.toStudyDurationDTO(study.getDuration()))
.consentInfo(study.getConsentInfo())
.status(StudyStatusDTO.fromValue(study.getStudyState().getValue()))
.start(study.getStartDate())
@@ -58,6 +61,6 @@ public static StudyDTO toStudyDTO_V1(Study study) {
}
public static Study.Status fromStatusChangeDTO_V1(StatusChangeDTO statusChangeDTO) {
- return Study.Status.valueOf(statusChangeDTO.getStatus().getValue().toUpperCase());
+ return Study.Status.fromValue(statusChangeDTO.getStatus().getValue());
}
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/TimelineTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/TimelineTransformer.java
new file mode 100644
index 00000000..6f62aef1
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/TimelineTransformer.java
@@ -0,0 +1,54 @@
+package io.redlink.more.studymanager.model.transformer;
+
+
+import io.redlink.more.studymanager.api.v1.model.InterventionTimelineEventDTO;
+import io.redlink.more.studymanager.api.v1.model.ObservationTimelineEventDTO;
+import io.redlink.more.studymanager.api.v1.model.StudyTimelineDTO;
+import io.redlink.more.studymanager.api.v1.model.StudyTimelineStudyDurationDTO;
+import io.redlink.more.studymanager.model.timeline.InterventionTimelineEvent;
+import io.redlink.more.studymanager.model.timeline.ObservationTimelineEvent;
+import io.redlink.more.studymanager.model.timeline.StudyTimeline;
+
+
+public class TimelineTransformer {
+ private TimelineTransformer() {}
+
+ public static StudyTimelineDTO toStudyTimelineDTO(StudyTimeline studyTimeline) {
+ return new StudyTimelineDTO()
+ .participantSignup(Transformers.toOffsetDateTime(studyTimeline.signup()))
+ .studyDuration(
+ new StudyTimelineStudyDurationDTO()
+ .from(studyTimeline.participationRange().getMinimum())
+ .to(studyTimeline.participationRange().getMaximum())
+ )
+ .observations(studyTimeline.observationTimelineEvents().stream().map(
+ TimelineTransformer::toObservationTimelineDTO
+ ).toList())
+ .interventions(studyTimeline.interventionTimelineEvents().stream().map(
+ TimelineTransformer::toInterventionTimelineEventDTO
+ ).toList());
+ }
+
+ public static ObservationTimelineEventDTO toObservationTimelineDTO(ObservationTimelineEvent observationTimelineEvent) {
+ return new ObservationTimelineEventDTO()
+ .observationId(observationTimelineEvent.observationId())
+ .studyGroupId(observationTimelineEvent.studyGroupId())
+ .title(observationTimelineEvent.title())
+ .purpose(observationTimelineEvent.purpose())
+ .type(observationTimelineEvent.type())
+ .start(Transformers.toOffsetDateTime(observationTimelineEvent.start()))
+ .end(Transformers.toOffsetDateTime(observationTimelineEvent.end()))
+ .hidden(observationTimelineEvent.hidden())
+ .scheduleType(observationTimelineEvent.scheduleType());
+ }
+
+ public static InterventionTimelineEventDTO toInterventionTimelineEventDTO(InterventionTimelineEvent interventionTimelineEvent) {
+ return new InterventionTimelineEventDTO()
+ .interventionId(interventionTimelineEvent.interventionId())
+ .studyGroupId(interventionTimelineEvent.studyGroupId())
+ .title(interventionTimelineEvent.title())
+ .purpose(interventionTimelineEvent.purpose())
+ .start(Transformers.toOffsetDateTime(interventionTimelineEvent.start()))
+ .scheduleType(interventionTimelineEvent.scheduleType());
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/properties/GatewayProperties.java b/studymanager/src/main/java/io/redlink/more/studymanager/properties/GatewayProperties.java
new file mode 100644
index 00000000..6dc942c2
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/properties/GatewayProperties.java
@@ -0,0 +1,17 @@
+package io.redlink.more.studymanager.properties;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "more.gateway")
+public class GatewayProperties {
+ private String baseUrl;
+
+ public String getBaseUrl() {
+ return baseUrl;
+ }
+
+ public GatewayProperties setBaseUrl(String baseUrl) {
+ this.baseUrl = baseUrl;
+ return this;
+ }
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/IntegrationRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/IntegrationRepository.java
index 8ddbce16..fee46e37 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/IntegrationRepository.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/IntegrationRepository.java
@@ -37,7 +37,12 @@ public class IntegrationRepository {
"DELETE FROM observation_api_tokens " +
"WHERE study_id = ? AND observation_id = ? AND token_id = ?";
private static final String DELETE_ALL = "DELETE FROM observation_api_tokens";
-
+ private static final String UPDATE_TOKEN = """
+ UPDATE observation_api_tokens
+ SET token_label = :token_label
+ WHERE study_id = :study_id AND observation_id = :observation_id AND token_id = :token_id
+ RETURNING token_id, token_label, created
+ """;
private static final String DELETE_ALL_FOR_STUDY_ID =
"DELETE FROM observation_api_tokens " +
"WHERE study_id = ?";
@@ -87,6 +92,23 @@ public void deleteToken(Long studyId, Integer observationId, Integer tokenId) {
template.update(DELETE_TOKEN, studyId, observationId, tokenId);
}
+ public Optional updateToken(Long studyId, Integer observationId, Integer tokenId, String tokenLabel) {
+ try {
+ return Optional.ofNullable(
+ namedTemplate.queryForObject(UPDATE_TOKEN,
+ new MapSqlParameterSource()
+ .addValue("study_id", studyId)
+ .addValue("observation_id", observationId)
+ .addValue("token_id", tokenId)
+ .addValue("token_label", tokenLabel),
+ getHiddenTokenRowMapper()
+ )
+ );
+ } catch (EmptyResultDataAccessException e) {
+ return Optional.empty();
+ }
+ }
+
private static RowMapper getHiddenTokenRowMapper() {
return (rs, rowNum) -> new EndpointToken(
rs.getInt("token_id"),
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java
index 23f5f5a0..03788160 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java
@@ -12,7 +12,7 @@
import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.model.Action;
-import io.redlink.more.studymanager.model.Event;
+import io.redlink.more.studymanager.model.scheduler.Event;
import io.redlink.more.studymanager.model.Intervention;
import io.redlink.more.studymanager.model.Trigger;
import io.redlink.more.studymanager.utils.MapperUtils;
@@ -22,8 +22,6 @@
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
-import org.springframework.jdbc.support.GeneratedKeyHolder;
-import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -33,18 +31,22 @@
@Component
public class InterventionRepository {
- private static final String INSERT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule) VALUES (:study_id,(SELECT COALESCE(MAX(intervention_id),0)+1 FROM interventions WHERE study_id = :study_id),:title,:purpose,:study_group_id,:schedule::jsonb)";
+ private static final String INSERT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule) VALUES (:study_id,(SELECT COALESCE(MAX(intervention_id),0)+1 FROM interventions WHERE study_id = :study_id),:title,:purpose,:study_group_id,:schedule::jsonb) RETURNING *";
+ private static final String IMPORT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule) VALUES (:study_id,:intervention_id,:title,:purpose,:study_group_id,:schedule::jsonb) RETURNING *";
private static final String GET_INTERVENTION_BY_IDS = "SELECT * FROM interventions WHERE study_id = ? AND intervention_id = ?";
private static final String LIST_INTERVENTIONS = "SELECT * FROM interventions WHERE study_id = ?";
+ private static final String LIST_INTERVENTIONS_FOR_GROUP = "SELECT * FROM interventions WHERE study_id = :study_id AND (study_group_id IS NULL OR study_group_id = :study_group_id)";
private static final String DELETE_INTERVENTION_BY_IDS = "DELETE FROM interventions WHERE study_id = ? AND intervention_id = ?";
private static final String DELETE_ALL = "DELETE FROM interventions";
private static final String UPDATE_INTERVENTION = "UPDATE interventions SET title=:title, study_group_id=:study_group_id, purpose=:purpose, schedule=:schedule::jsonb WHERE study_id=:study_id AND intervention_id=:intervention_id";
- private static final String CREATE_ACTION = "INSERT INTO actions(study_id,intervention_id,action_id,type,properties) VALUES (:study_id,:intervention_id,(SELECT COALESCE(MAX(action_id),0)+1 FROM actions WHERE study_id = :study_id AND intervention_id=:intervention_id),:type,:properties::jsonb)";
+ private static final String CREATE_ACTION = "INSERT INTO actions(study_id,intervention_id,action_id,type,properties) VALUES (:study_id,:intervention_id,(SELECT COALESCE(MAX(action_id),0)+1 FROM actions WHERE study_id = :study_id AND intervention_id=:intervention_id),:type,:properties::jsonb) RETURNING *";
+ private static final String IMPORT_ACTION = "INSERT INTO actions(study_id,intervention_id,action_id,type,properties) VALUES (:study_id,:intervention_id,:action_id,:type,:properties::jsonb) RETURNING *";
private static final String GET_ACTION_BY_IDS = "SELECT * FROM actions WHERE study_id=? AND intervention_id=? AND action_id=?";
private static final String LIST_ACTIONS = "SELECT * FROM actions WHERE study_id = ? AND intervention_id = ?";
private static final String DELETE_ACTION_BY_ID = "DELETE FROM actions WHERE study_id = ? AND intervention_id = ? AND action_id = ?";
private static final String UPDATE_ACTION = "UPDATE actions SET properties=:properties::jsonb WHERE study_id=:study_id AND intervention_id=:intervention_id AND action_id=:action_id";
private static final String UPSERT_TRIGGER = "INSERT INTO triggers(study_id,intervention_id,type,properties) VALUES(:study_id,:intervention_id,:type,:properties::jsonb) ON CONFLICT ON CONSTRAINT triggers_pkey DO UPDATE SET type=:type, properties=:properties::jsonb, modified = now()";
+ private static final String IMPORT_TRIGGER = "INSERT INTO triggers(study_id,intervention_id,type,properties) VALUES(:study_id,:intervention_id,:type,:properties::jsonb) RETURNING *";
private static final String GET_TRIGGER_BY_IDS = "SELECT * FROM triggers WHERE study_id = ? AND intervention_id = ?";
private final JdbcTemplate template;
private final NamedParameterJdbcTemplate namedTemplate;
@@ -55,19 +57,42 @@ public InterventionRepository(JdbcTemplate template) {
}
public Intervention insert(Intervention intervention) {
- final KeyHolder keyHolder = new GeneratedKeyHolder();
try {
- namedTemplate.update(INSERT_INTERVENTION, interventionToParams(intervention), keyHolder, new String[] { "intervention_id" });
+ return namedTemplate.queryForObject(INSERT_INTERVENTION, interventionToParams(intervention), getInterventionRowMapper());
} catch (DataIntegrityViolationException e) {
throw new BadRequestException("Study group " + intervention.getStudyGroupId() + " does not exist on study " + intervention.getStudyId());
}
- return getByIds(intervention.getStudyId(), keyHolder.getKey().intValue());
+ }
+
+ public Intervention importIntervention(Long studyId, Intervention intervention) {
+ try {
+ return namedTemplate.queryForObject(IMPORT_INTERVENTION,
+ interventionToParams(intervention)
+ .addValue("study_id", studyId)
+ .addValue("intervention_id", intervention.getInterventionId()),
+ getInterventionRowMapper());
+ } catch (DataIntegrityViolationException e) {
+ throw new BadRequestException(
+ "Error during import of intervention " +
+ intervention.getInterventionId() +
+ "for study " +
+ intervention.getStudyId()
+ );
+ }
}
public List listInterventions(Long studyId) {
return template.query(LIST_INTERVENTIONS, getInterventionRowMapper(), studyId);
}
+ public List listInterventionsForGroup(Long studyId, Integer groupId) {
+ return namedTemplate.query(LIST_INTERVENTIONS_FOR_GROUP,
+ new MapSqlParameterSource("study_id", studyId)
+ .addValue("study_group_id", groupId),
+ getInterventionRowMapper()
+ );
+ }
+
public Intervention getByIds(Long studyId, Integer interventionId) {
return template.queryForObject(GET_INTERVENTION_BY_IDS, getInterventionRowMapper(), studyId, interventionId);
}
@@ -86,6 +111,23 @@ public Trigger updateTrigger(Long studyId, Integer interventionId, Trigger trigg
return getTriggerByIds(studyId, interventionId);
}
+ public Trigger importTrigger(Long studyId, Integer interventionId, Trigger trigger) {
+ try {
+ return namedTemplate.queryForObject(
+ IMPORT_TRIGGER,
+ triggerToParams(studyId, interventionId, trigger),
+ getTriggerRowMapper()
+ );
+ } catch (DataIntegrityViolationException e) {
+ throw new BadRequestException(
+ "Error during import of trigger for intervention " +
+ interventionId +
+ "for study " +
+ studyId
+ );
+ }
+ }
+
public Trigger getTriggerByIds(Long studyId, Integer interventionId) {
try {
return template.queryForObject(GET_TRIGGER_BY_IDS, getTriggerRowMapper(), studyId, interventionId);
@@ -95,13 +137,29 @@ public Trigger getTriggerByIds(Long studyId, Integer interventionId) {
}
public Action createAction(Long studyId, Integer interventionId, Action action) {
- final KeyHolder keyHolder = new GeneratedKeyHolder();
try {
- namedTemplate.update(CREATE_ACTION, actionToParams(studyId, interventionId, action), keyHolder, new String[] { "action_id" });
+ return namedTemplate.queryForObject(CREATE_ACTION, actionToParams(studyId, interventionId, action), getActionRowMapper());
} catch (DataIntegrityViolationException e) {
throw new BadRequestException("Intervention " + interventionId + " does not exist on study " + studyId);
}
- return getActionByIds(studyId, interventionId, keyHolder.getKey().intValue());
+ }
+
+ public Action importAction(Long studyId, Integer interventionId, Action action) {
+ try {
+ return namedTemplate.queryForObject(
+ IMPORT_ACTION,
+ actionToParams(studyId, interventionId, action)
+ .addValue("action_id", action.getActionId()),
+ getActionRowMapper()
+ );
+ } catch (DataIntegrityViolationException e) {
+ throw new BadRequestException(
+ "Error during import of action for intervention " +
+ interventionId +
+ "for study " +
+ studyId
+ );
+ }
}
public Action getActionByIds(Long studyId, Integer interventionId, Integer actionId) {
@@ -179,5 +237,4 @@ private static RowMapper getInterventionRowMapper() {
.setCreated(RepositoryUtils.readInstant(rs,"created"))
.setModified(RepositoryUtils.readInstant(rs,"modified"));
}
-
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java
index 26adcfb8..1868ea3e 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/NameValuePairRepository.java
@@ -19,9 +19,15 @@
@Component
public class NameValuePairRepository {
- private static final String UPSERT = "INSERT INTO nvpairs(issuer,name,value) VALUES (?,?,?) ON CONFLICT(issuer,name) DO UPDATE SET value = EXCLUDED.value";
- private static final String READ = "SELECT value FROM nvpairs WHERE issuer = ? AND name = ? LIMIT 1";
- private static final String REMOVE = "DELETE FROM nvpairs WHERE issuer = ? AND name = ?";
+ 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 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 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 final JdbcTemplate template;
@@ -29,25 +35,68 @@ public NameValuePairRepository(JdbcTemplate template) {
this.template = template;
}
- public void setValue(String issuer, String name, T value) {
- this.template.update(UPSERT, issuer, name, SerializationUtils.serialize(value));
+ public void setObservationValue(Long studyId, int observationId, String name, T value) {
+ this.template.update(UPSERT_O, studyId, observationId, name, SerializationUtils.serialize(value));
}
- public Optional getValue(String issuer, String name, Class tClass) {
+ public void setTriggerValue(Long studyId, int interventionId, String name, T value) {
+ this.template.update(UPSERT_T, studyId, interventionId, name, SerializationUtils.serialize(value));
+ }
+
+ public void setActionValue(Long studyId, int interventionId, int actionId, String name, T value) {
+ this.template.update(UPSERT_A, studyId, interventionId, actionId, name, SerializationUtils.serialize(value));
+ }
+
+ public Optional getObservationValue(Long studyId, int observationId, String name, Class tClass) {
+ try {
+ return Optional.ofNullable(this.template.queryForObject(READ_O,
+ (rs, rowNum) -> tClass.cast(SerializationUtils.deserialize(rs.getBytes("value"))),
+ studyId, observationId, name));
+ } catch (EmptyResultDataAccessException e) {
+ return Optional.empty();
+ }
+ }
+
+ public Optional getTriggerValue(Long studyId, int interventionId, String name, Class tClass) {
+ try {
+ return Optional.ofNullable(this.template.queryForObject(READ_T,
+ (rs, rowNum) -> tClass.cast(SerializationUtils.deserialize(rs.getBytes("value"))),
+ studyId, interventionId, name));
+ } catch (EmptyResultDataAccessException e) {
+ return Optional.empty();
+ }
+ }
+
+ public Optional getActionValue(Long studyId, int interventionId, int actionId, String name, Class tClass) {
try {
- return Optional.ofNullable(this.template.queryForObject(READ,
+ return Optional.ofNullable(this.template.queryForObject(READ_A,
(rs, rowNum) -> tClass.cast(SerializationUtils.deserialize(rs.getBytes("value"))),
- issuer, name));
+ studyId, interventionId, actionId, name));
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
- public void removeValue(String issuer, String name) {
- this.template.update(REMOVE, issuer, name);
+ public void removeObservationValue(Long studyId, int observationId, String name) {
+ this.template.update(REMOVE_O, studyId, observationId, name);
+ }
+
+ public void removeTriggerValue(Long studyId, int interventionId, String name) {
+ this.template.update(REMOVE_T, studyId, interventionId, name);
+ }
+
+ public void removeActionValue(Long studyId, int interventionId, int actionId, String name) {
+ this.template.update(REMOVE_A, studyId, interventionId, actionId, name);
+ }
+
+ protected boolean noObservationValues() {
+ return this.template.queryForObject(
+ "SELECT count(*) AS c FROM nvpairs_observations", Integer.class) == 0;
}
void clear() {
- this.template.execute("DELETE FROM nvpairs");
+ this.template.execute("DELETE FROM nvpairs_observations");
+ this.template.execute("DELETE FROM nvpairs_triggers");
+ this.template.execute("DELETE FROM nvpairs_actions");
}
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java
index dd882f23..7b641f11 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java
@@ -11,8 +11,8 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.exception.BadRequestException;
-import io.redlink.more.studymanager.model.Event;
import io.redlink.more.studymanager.model.Observation;
+import io.redlink.more.studymanager.model.scheduler.ScheduleEvent;
import io.redlink.more.studymanager.utils.MapperUtils;
import java.util.List;
import java.util.Optional;
@@ -23,8 +23,6 @@
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
-import org.springframework.jdbc.support.GeneratedKeyHolder;
-import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
import static io.redlink.more.studymanager.repository.RepositoryUtils.getValidNullableIntegerValue;
@@ -32,10 +30,12 @@
@Component
public class ObservationRepository {
- private static final String INSERT_NEW_OBSERVATION = "INSERT INTO observations(study_id,observation_id,title,purpose,participant_info,type,study_group_id,properties,schedule,hidden,no_schedule) VALUES (:study_id,(SELECT COALESCE(MAX(observation_id),0)+1 FROM observations WHERE study_id = :study_id),:title,:purpose,:participant_info,:type,:study_group_id,:properties::jsonb,:schedule::jsonb,:hidden,:no_schedule)";
+ private static final String INSERT_NEW_OBSERVATION = "INSERT INTO observations(study_id,observation_id,title,purpose,participant_info,type,study_group_id,properties,schedule,hidden,no_schedule) VALUES (:study_id,(SELECT COALESCE(MAX(observation_id),0)+1 FROM observations WHERE study_id = :study_id),:title,:purpose,:participant_info,:type,:study_group_id,:properties::jsonb,:schedule::jsonb,:hidden,:no_schedule) RETURNING *";
+ private static final String IMPORT_OBSERVATION = "INSERT INTO observations(study_id,observation_id,title,purpose,participant_info,type,study_group_id,properties,schedule,hidden,no_schedule) VALUES (:study_id,:observation_id,:title,:purpose,:participant_info,:type,:study_group_id,:properties::jsonb,:schedule::jsonb,:hidden,:no_schedule) RETURNING *";
private static final String GET_OBSERVATION_BY_IDS = "SELECT * FROM observations WHERE study_id = ? AND observation_id = ?";
private static final String DELETE_BY_IDS = "DELETE FROM observations WHERE study_id = ? AND observation_id = ?";
- private static final String LIST_OBSERVATIONS = "SELECT * FROM observations WHERE study_id = ?";
+ private static final String LIST_OBSERVATIONS = "SELECT * FROM observations WHERE study_id = :study_id";
+ private static final String LIST_OBSERVATIONS_FOR_GROUP = "SELECT * FROM observations WHERE study_id = :study_id AND (study_group_id IS NULL OR study_group_id = :study_group_id)";
private static final String UPDATE_OBSERVATION = "UPDATE observations SET title=:title, purpose=:purpose, participant_info=:participant_info, study_group_id=:study_group_id, properties=:properties::jsonb, schedule=:schedule::jsonb, modified=now(), hidden=:hidden, no_schedule=:no_schedule WHERE study_id=:study_id AND observation_id=:observation_id";
private static final String DELETE_ALL = "DELETE FROM observations";
private static final String SET_OBSERVATION_PROPERTIES_FOR_PARTICIPANT = "INSERT INTO participant_observation_properties(study_id,participant_id,observation_id,properties) VALUES (:study_id,:participant_id,:observation_id,:properties::jsonb) ON CONFLICT (study_id, participant_id, observation_id) DO UPDATE SET properties = EXCLUDED.properties";
@@ -51,13 +51,30 @@ public ObservationRepository(JdbcTemplate template) {
}
public Observation insert(Observation observation) {
- final KeyHolder keyHolder = new GeneratedKeyHolder();
try {
- namedTemplate.update(INSERT_NEW_OBSERVATION, toParams(observation), keyHolder, new String[] { "observation_id" });
+ return namedTemplate.queryForObject(INSERT_NEW_OBSERVATION, toParams(observation), getObservationRowMapper());
} catch (DataIntegrityViolationException | JsonProcessingException e) {
throw new BadRequestException("Study group " + observation.getStudyGroupId() + " does not exist on study " + observation.getStudyId());
}
- return getById(observation.getStudyId(), keyHolder.getKey().intValue());
+ }
+
+ public Observation doImport(Long studyId, Observation observation) {
+ try {
+ return namedTemplate.queryForObject(
+ IMPORT_OBSERVATION,
+ toParams(observation)
+ .addValue("study_id", studyId)
+ .addValue("observation_id", observation.getObservationId()),
+ getObservationRowMapper()
+ );
+ } catch (DataIntegrityViolationException | JsonProcessingException e) {
+ throw new BadRequestException(
+ "Error during import of observation " +
+ observation.getObservationId() +
+ "for study " +
+ observation.getStudyId()
+ );
+ }
}
public Observation getById(Long studyId, Integer observationId) {
@@ -73,7 +90,20 @@ public void deleteObservation(Long studyId, Integer observationId) {
}
public List listObservations(Long studyId) {
- return template.query(LIST_OBSERVATIONS, getObservationRowMapper(), studyId);
+ return namedTemplate.query(
+ LIST_OBSERVATIONS,
+ new MapSqlParameterSource("study_id", studyId),
+ getObservationRowMapper()
+ );
+ }
+
+ public List listObservationsForGroup(Long studyId, Integer studyGroupId) {
+ return namedTemplate.query(
+ LIST_OBSERVATIONS_FOR_GROUP,
+ new MapSqlParameterSource("study_id", studyId)
+ .addValue("study_group_id", studyGroupId),
+ getObservationRowMapper()
+ );
}
public Observation updateObservation(Observation observation) {
@@ -145,7 +175,7 @@ private static RowMapper getObservationRowMapper() {
.setType(rs.getString("type"))
.setStudyGroupId(getValidNullableIntegerValue(rs, "study_group_id"))
.setProperties(MapperUtils.readValue(rs.getString("properties"), ObservationProperties.class))
- .setSchedule(MapperUtils.readValue(rs.getString("schedule"), Event.class))
+ .setSchedule(MapperUtils.readValue(rs.getString("schedule"), ScheduleEvent.class))
.setCreated(RepositoryUtils.readInstant(rs, "created"))
.setModified(RepositoryUtils.readInstant(rs, "modified"))
.setHidden(rs.getBoolean("hidden"))
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java
index 5855b231..de03f205 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java
@@ -8,6 +8,7 @@
*/
package io.redlink.more.studymanager.repository;
+import com.google.common.base.Supplier;
import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.model.Participant;
import java.util.List;
@@ -24,15 +25,20 @@
import org.springframework.transaction.annotation.Transactional;
import static io.redlink.more.studymanager.repository.RepositoryUtils.getValidNullableIntegerValue;
-import static io.redlink.more.studymanager.repository.RepositoryUtils.toParam;
+import static io.redlink.more.studymanager.repository.RepositoryUtils.intReader;
@Component
public class ParticipantRepository {
private static final String INSERT_PARTICIPANT_AND_TOKEN =
"WITH p AS (INSERT INTO participants(study_id,participant_id,alias,study_group_id) VALUES (:study_id,(SELECT COALESCE(MAX(participant_id),0)+1 FROM participants WHERE study_id = :study_id),:alias,:study_group_id) RETURNING participant_id, study_id) INSERT INTO registration_tokens(participant_id,study_id,token) SELECT participant_id, study_id, :token FROM p";
- private static final String GET_PARTICIPANT_BY_IDS = "SELECT p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, p.modified FROM participants p LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id WHERE p.study_id = ? AND p.participant_id = ?";
- private static final String LIST_PARTICIPANTS_BY_STUDY = "SELECT p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, p.modified FROM participants p LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id WHERE p.study_id = ?";
+ private static final String UPDATE_REGISTRATION_TOKEN = """
+ INSERT INTO registration_tokens(study_id, participant_id, token)
+ VALUES (:study_id, :participant_id, :token)
+ ON CONFLICT (study_id, participant_id) DO UPDATE SET token = excluded.token
+ """;
+ private static final String GET_PARTICIPANT_BY_IDS = "SELECT p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, p.modified, p.start FROM participants p LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id WHERE p.study_id = ? AND p.participant_id = ?";
+ private static final String LIST_PARTICIPANTS_BY_STUDY = "SELECT p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, p.modified, p.start FROM participants p LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id WHERE p.study_id = ?";
private static final String DELETE_PARTICIPANT =
"DELETE FROM participants " +
"WHERE study_id=? AND participant_id=?";
@@ -49,6 +55,18 @@ public class ParticipantRepository {
"WHERE study_id = :study_id AND participant_id = :participant_id " +
" AND status = :current_status::participant_status " +
"RETURNING *, (SELECT token FROM registration_tokens t WHERE t.study_id = p.study_id AND t.participant_id = p.participant_id ) as token";
+
+ private static final String LIST_PARTICIPANTS_FOR_CLOSING =
+ "SELECT DISTINCT p.*, 't' as token " +
+ "FROM studies s " +
+ " JOIN participants p ON s.study_id = p.study_id " +
+ " LEFT JOIN study_groups sg ON p.study_group_id = sg.study_group_id AND p.study_id = sg.study_id " +
+ "WHERE s.status = 'active' " +
+ " AND p.status = 'active' " +
+ " AND p.start IS NOT NULL " +
+ " AND COALESCE(sg.duration, s.duration) IS NOT NULL " +
+ " AND (p.start + ((COALESCE(sg.duration, s.duration)->>'value')::int || ' ' || (COALESCE(sg.duration, s.duration)->>'unit'))::interval) < NOW()";
+
private static final String DELETE_ALL = "DELETE FROM participants";
private final JdbcTemplate template;
private final NamedParameterJdbcTemplate namedTemplate;
@@ -81,6 +99,10 @@ public List listParticipants(Long studyId) {
return template.query(LIST_PARTICIPANTS_BY_STUDY, getParticipantRowMapper(), studyId);
}
+ public List listParticipantsForClosing() {
+ return template.query(LIST_PARTICIPANTS_FOR_CLOSING, getParticipantRowMapper());
+ }
+
@Transactional
public void deleteParticipant(Long studyId, Integer participantId) {
template.update(DELETE_PARTICIPANT, studyId, participantId);
@@ -116,6 +138,25 @@ public void cleanupParticipants(Long studyId) {
namedTemplate.update("DELETE FROM push_notifications_token WHERE study_id = :study_id", params);
}
+ @Transactional
+ public void resetParticipants(final Long studyId, final Supplier tokenSource) {
+ // First clear credentials and tokens...
+ cleanupParticipants(studyId);
+ // ... then reset participant-status and start-date ...
+ final var pIDs = namedTemplate.query(
+ "UPDATE participants SET status = DEFAULT, start = NULL WHERE study_id = :study_id RETURNING *",
+ toParams(studyId),
+ intReader("participant_id")
+ );
+ // ... and finally create new token for the participants
+ namedTemplate.batchUpdate(
+ UPDATE_REGISTRATION_TOKEN,
+ pIDs.stream()
+ .map(pid -> toParams(studyId, pid).addValue("token", tokenSource.get()))
+ .toArray(MapSqlParameterSource[]::new)
+ );
+ }
+
public void clear() {
template.update(DELETE_ALL);
}
@@ -147,6 +188,7 @@ private static RowMapper getParticipantRowMapper() {
.setCreated(RepositoryUtils.readInstant(rs, "created"))
.setModified(RepositoryUtils.readInstant(rs, "modified"))
.setStatus(RepositoryUtils.readParticipantStatus(rs, "status"))
+ .setStart(RepositoryUtils.readInstant(rs, "start"))
.setRegistrationToken(rs.getString("token"));
}
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java
index 384e6f74..5c6154d3 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java
@@ -11,6 +11,7 @@
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
+import org.springframework.jdbc.core.RowMapper;
public final class RepositoryUtils {
@@ -69,4 +70,12 @@ public static String toParam(Participant.Status status) {
case LOCKED -> "locked";
};
}
+
+ public static RowMapper intReader(String columnLabel) {
+ return (rs, rowNum) -> {
+ int anInt = rs.getInt(columnLabel);
+ if (rs.wasNull()) return null;
+ return anInt;
+ };
+ }
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyGroupRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyGroupRepository.java
index 6158246d..e29fb206 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyGroupRepository.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyGroupRepository.java
@@ -10,25 +10,26 @@
import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.model.StudyGroup;
+import io.redlink.more.studymanager.model.scheduler.Duration;
+import io.redlink.more.studymanager.utils.MapperUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
-import org.springframework.jdbc.support.GeneratedKeyHolder;
-import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class StudyGroupRepository {
- private static final String INSERT_STUDY_GROUP = "INSERT INTO study_groups (study_id,study_group_id,title,purpose) VALUES (:study_id,(SELECT COALESCE(MAX(study_group_id),0)+1 FROM study_groups WHERE study_id = :study_id),:title,:purpose)";
+ private static final String INSERT_STUDY_GROUP = "INSERT INTO study_groups (study_id,study_group_id,title,purpose) VALUES (:study_id,(SELECT COALESCE(MAX(study_group_id),0)+1 FROM study_groups WHERE study_id = :study_id),:title,:purpose) RETURNING *";
+ private static final String IMPORT_STUDY_GROUP = "INSERT INTO study_groups (study_id, study_group_id, title, purpose) VALUES (:study_id,:study_group_id,:title,:purpose) RETURNING *";
private static final String GET_STUDY_GROUP_BY_IDS = "SELECT * FROM study_groups WHERE study_id = ? AND study_group_id = ?";
private static final String LIST_STUDY_GROUPS_ORDER_BY_STUDY_GROUP_ID = "SELECT * FROM study_groups WHERE study_id = ? ORDER BY study_group_id";
private static final String UPDATE_STUDY =
- "UPDATE study_groups SET title = :title, purpose = :purpose, modified = now() WHERE study_id = :study_id AND study_group_id = :study_group_id";
+ "UPDATE study_groups SET title = :title, purpose = :purpose, duration = :duration::jsonb, modified = now() WHERE study_id = :study_id AND study_group_id = :study_group_id";
private static final String DELETE_STUDY_GROUP_BY_ID = "DELETE FROM study_groups WHERE study_id = ? AND study_group_id = ?";
private static final String CLEAR_STUDY_GROUPS = "DELETE FROM study_groups";
@@ -42,13 +43,30 @@ public StudyGroupRepository(JdbcTemplate template) {
}
public StudyGroup insert(StudyGroup studyGroup) {
- final KeyHolder keyHolder = new GeneratedKeyHolder();
try {
- namedTemplate.update(INSERT_STUDY_GROUP, toParams(studyGroup), keyHolder, new String[] { "study_group_id" });
+ return namedTemplate.queryForObject(INSERT_STUDY_GROUP, toParams(studyGroup), getStudyGroupRowMapper());
} catch (DataIntegrityViolationException e) {
throw new BadRequestException("Study " + studyGroup.getStudyId() + " does not exist");
}
- return getByIds(studyGroup.getStudyId(), keyHolder.getKey().intValue());
+ }
+
+ public StudyGroup doImport(Long studyId, StudyGroup studyGroup) {
+ try {
+ return namedTemplate.queryForObject(
+ IMPORT_STUDY_GROUP,
+ toParams(studyGroup)
+ .addValue("study_id", studyId)
+ .addValue("study_group_id", studyGroup.getStudyGroupId()),
+ getStudyGroupRowMapper()
+ );
+ } catch (DataIntegrityViolationException e) {
+ throw new BadRequestException(
+ "Error during import of studyGroup " +
+ studyGroup.getStudyGroupId() +
+ "for study " +
+ studyGroup.getStudyId()
+ );
+ }
}
public StudyGroup getByIds(long studyId, int studyGroupId) {
@@ -78,7 +96,8 @@ private static MapSqlParameterSource toParams(StudyGroup studyGroup) {
return new MapSqlParameterSource()
.addValue("study_id", studyGroup.getStudyId())
.addValue("title", studyGroup.getTitle())
- .addValue("purpose", studyGroup.getPurpose());
+ .addValue("purpose", studyGroup.getPurpose())
+ .addValue("duration", MapperUtils.writeValueAsString(studyGroup.getDuration()));
}
private static RowMapper getStudyGroupRowMapper() {
@@ -87,6 +106,7 @@ private static RowMapper getStudyGroupRowMapper() {
.setStudyGroupId(rs.getInt("study_group_id"))
.setTitle(rs.getString("title"))
.setPurpose(rs.getString("purpose"))
+ .setDuration(MapperUtils.readValue(rs.getString("duration"), Duration.class))
.setCreated(RepositoryUtils.readInstant(rs, "created"))
.setModified(RepositoryUtils.readInstant(rs, "modified"));
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java
index fe349115..a3364087 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java
@@ -12,6 +12,8 @@
import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.model.StudyRole;
import io.redlink.more.studymanager.model.User;
+import io.redlink.more.studymanager.model.scheduler.Duration;
+import io.redlink.more.studymanager.utils.MapperUtils;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -25,8 +27,8 @@
public class StudyRepository {
private static final String INSERT_STUDY =
- "INSERT INTO studies (title,purpose,participant_info,consent_info,finish_text,planned_start_date,planned_end_date,institute,contact_person,contact_email,contact_phone) " +
- "VALUES (:title,:purpose,:participant_info,:consent_info,:finish_text,:planned_start_date,:planned_end_date,:institute,:contact_person,:contact_email,:contact_phone) " +
+ "INSERT INTO studies (title,purpose,participant_info,consent_info,finish_text,planned_start_date,planned_end_date,duration,institute,contact_person,contact_email,contact_phone) " +
+ "VALUES (:title,:purpose,:participant_info,:consent_info,:finish_text,:planned_start_date,:planned_end_date,:duration::jsonb,:institute,:contact_person,:contact_email,:contact_phone) " +
"RETURNING *";
private static final String GET_STUDY_BY_ID =
"SELECT *, " +
@@ -48,16 +50,20 @@ public class StudyRepository {
"ORDER BY modified DESC";
private static final String UPDATE_STUDY =
"UPDATE studies SET title = :title, purpose = :purpose, participant_info = :participant_info, consent_info = :consent_info, finish_text = :finish_text, planned_start_date = :planned_start_date, " +
- "planned_end_date = :planned_end_date, modified = now(), institute = :institute, contact_person = :contact_person, contact_email = :contact_email, contact_phone = :contact_phone " +
+ "planned_end_date = :planned_end_date, duration = :duration::jsonb, modified = now(), institute = :institute, contact_person = :contact_person, contact_email = :contact_email, contact_phone = :contact_phone " +
"WHERE study_id = :study_id " +
"RETURNING *, (SELECT user_roles FROM study_roles_by_user WHERE study_roles_by_user.study_id = studies.study_id AND user_id = :userId) AS user_roles";
private static final String DELETE_BY_ID = "DELETE FROM studies WHERE study_id = ?";
private static final String CLEAR_STUDIES = "DELETE FROM studies";
- private static final String SET_DRAFT_STATE_BY_ID = "UPDATE studies SET status = 'draft', start_date = NULL, end_date = NULL, modified = now() WHERE study_id = ?";
- private static final String SET_ACTIVE_STATE_BY_ID = "UPDATE studies SET status = 'active', start_date = now(), modified = now() WHERE study_id = ?";
- private static final String SET_PAUSED_STATE_BY_ID = "UPDATE studies SET status = 'paused', modified = now() WHERE study_id = ?";
- private static final String SET_CLOSED_STATE_BY_ID = "UPDATE studies SET status = 'closed', end_date = now(), modified = now() WHERE study_id = ?";
+ private static final String SET_STUDY_STATE = """
+ UPDATE studies
+ SET status = :newState::study_state,
+ modified = now(),
+ start_date = CASE WHEN :setStart = 0 THEN NULL WHEN :setStart = 1 THEN now() ELSE start_date END,
+ end_date = CASE WHEN :setEnd = 0 THEN NULL WHEN :setEnd = 1 THEN now() ELSE end_date END
+ WHERE study_id = :studyId
+ RETURNING *""";
private static final String STUDY_HAS_STATE = "SELECT study_id FROM studies WHERE study_id = :study_id AND status::varchar IN (:study_status)";
private final JdbcTemplate template;
@@ -113,17 +119,29 @@ public void deleteById(long id) {
template.update(DELETE_BY_ID, id);
}
- public void setStateById(long id, Study.Status status) {
- template.update(getStatusQuery(status), id);
- }
+ public Optional setStateById(long id, Study.Status status) {
+ final int toNull = 0, toNow = 1, keepCurrentValue = -1;
+ int setStart = keepCurrentValue, setEnd = keepCurrentValue;
+ switch (status) {
+ case DRAFT -> {
+ setStart = toNull;
+ setEnd = toNull;
+ }
+ case ACTIVE, PREVIEW -> setStart = toNow;
+ case CLOSED -> setEnd = toNow;
+ }
- private String getStatusQuery(Study.Status status) {
- return switch (status) {
- case DRAFT -> SET_DRAFT_STATE_BY_ID;
- case ACTIVE -> SET_ACTIVE_STATE_BY_ID;
- case PAUSED -> SET_PAUSED_STATE_BY_ID;
- case CLOSED -> SET_CLOSED_STATE_BY_ID;
- };
+ try (var stream = namedTemplate.queryForStream(SET_STUDY_STATE,
+ new MapSqlParameterSource()
+ .addValue("studyId", id)
+ .addValue("newState", status.getValue())
+ .addValue("setStart", setStart)
+ .addValue("setEnd", setEnd),
+ getStudyRowMapper()
+ )) {
+ return stream
+ .findFirst();
+ }
}
private static MapSqlParameterSource studyToParams(Study study) {
@@ -135,6 +153,7 @@ private static MapSqlParameterSource studyToParams(Study study) {
.addValue("finish_text", study.getFinishText())
.addValue("planned_start_date", study.getPlannedStartDate())
.addValue("planned_end_date", study.getPlannedEndDate())
+ .addValue("duration", MapperUtils.writeValueAsString(study.getDuration()))
.addValue("institute", study.getContact().getInstitute())
.addValue("contact_person", study.getContact().getPerson())
.addValue("contact_email", study.getContact().getEmail())
@@ -154,9 +173,10 @@ private static RowMapper getStudyRowMapper() {
.setPlannedEndDate(RepositoryUtils.readLocalDate(rs,"planned_end_date"))
.setStartDate(RepositoryUtils.readLocalDate(rs,"start_date"))
.setEndDate(RepositoryUtils.readLocalDate(rs,"end_date"))
+ .setDuration(MapperUtils.readValue(rs.getString("duration"), Duration.class))
.setCreated(RepositoryUtils.readInstant(rs, "created"))
.setModified(RepositoryUtils.readInstant(rs, "modified"))
- .setStudyState(Study.Status.valueOf(rs.getString("status").toUpperCase()))
+ .setStudyState(Study.Status.fromValue(rs.getString("status").toUpperCase()))
.setContact(new Contact()
.setInstitute(rs.getString("institute"))
.setPerson(rs.getString("contact_person"))
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java
index f8163475..9d532532 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/MoreSDK.java
@@ -8,6 +8,7 @@
*/
package io.redlink.more.studymanager.sdk;
+import io.redlink.more.studymanager.core.io.SimpleParticipant;
import io.redlink.more.studymanager.core.io.TimeRange;
import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.core.sdk.MoreActionSDK;
@@ -42,7 +43,7 @@ public class MoreSDK {
private static final Logger LOGGER = LoggerFactory.getLogger(MoreSDK.class);
- private final NameValuePairRepository nvpairs;
+ public final NameValuePairRepository nvpairs;
private final SchedulingService schedulingService;
@@ -68,18 +69,6 @@ public MoreSDK(
this.observationRepository = observationRepository;
}
- public void setValue(String issuer, String name, T value) {
- nvpairs.setValue(issuer, name, value);
- }
-
- public Optional getValue(String issuer, String name, Class tClass) {
- return nvpairs.getValue(issuer, name, tClass);
- }
-
- public void removeValue(String issuer, String name) {
- nvpairs.removeValue(issuer, name);
- }
-
public MoreActionSDK scopedActionSDK(Long studyId, Integer studyGroupId, int interventionId, int actionId, String actionType, int participantId) {
return new MoreActionSDKImpl(this, studyId, studyGroupId, interventionId, actionId, actionType, participantId);
}
@@ -110,16 +99,17 @@ public void removeSchedule(String issuer, String id) {
schedulingService.unscheduleJob(issuer, id, TriggerJob.class);
}
- public Set listParticipants(long studyId, Integer studyGroupId, Set status) {
+ public Set listParticipants(long studyId, Integer studyGroupId, Set status) {
return participantService.listParticipants(studyId).stream()
.filter(p -> studyGroupId == null || studyGroupId.equals(p.getStudyGroupId()))
.filter(p -> status == null || status.contains(p.getStatus()))
- .map(Participant::getParticipantId)
+ .map(p -> new SimpleParticipant(p.getParticipantId(), p.getStart()))
.collect(Collectors.toSet());
}
public Set listActiveParticipantsByQuery(long studyId, Integer studyGroupId, String query, TimeRange timerange) {
- Set participants = listParticipants(studyId, studyGroupId, Set.of(Participant.Status.ACTIVE));
+ Set participants = listParticipants(studyId, studyGroupId, Set.of(Participant.Status.ACTIVE))
+ .stream().map(SimpleParticipant::getId).collect(Collectors.toSet());
Set allThatMatchQuery = new HashSet<>(elasticService.participantsThatMapQuery(studyId, studyGroupId, query, timerange));
participants.retainAll(allThatMatchQuery);
return participants;
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreActionSDKImpl.java b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreActionSDKImpl.java
index d3e8cbb6..b9c81851 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreActionSDKImpl.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreActionSDKImpl.java
@@ -15,8 +15,10 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.io.Serializable;
import java.time.Instant;
import java.util.Map;
+import java.util.Optional;
public class MoreActionSDKImpl extends MorePlatformSDKImpl implements MoreActionSDK {
private static final Logger LOGGER = LoggerFactory.getLogger(MoreActionSDKImpl.class);
@@ -34,6 +36,21 @@ public MoreActionSDKImpl(MoreSDK sdk, long studyId, Integer studyGroupId, int in
this.participantId = participantId;
}
+ @Override
+ public void setValue(String name, T value) {
+ sdk.nvpairs.setActionValue(studyId, interventionId, actionId, name, value);
+ }
+
+ @Override
+ public Optional getValue(String name, Class tClass) {
+ return sdk.nvpairs.getActionValue(studyId, interventionId, actionId, name, tClass);
+ }
+
+ @Override
+ public void removeValue(String name) {
+ sdk.nvpairs.removeActionValue(studyId, interventionId, actionId, name);
+ }
+
@Override
public void sendPushNotification(String title, String message) {
try (var ctx = LoggingUtils.createContext()) {
@@ -68,9 +85,4 @@ public void triggerObservation(String title, String message, String factoryId, i
}
}
}
-
- @Override
- public String getIssuer() {
- return studyId + "-" + studyGroupId + '-' + interventionId + "-" + actionId + "-action";
- }
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreObservationSDKImpl.java b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreObservationSDKImpl.java
index 63a1580f..d984ba47 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreObservationSDKImpl.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreObservationSDKImpl.java
@@ -13,6 +13,7 @@
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;
@@ -27,8 +28,18 @@ public MoreObservationSDKImpl(MoreSDK sdk, long studyId, Integer studyGroupId, i
}
@Override
- public String getIssuer() {
- return studyId + "-" + studyGroupId + '-' + observationId + "-observation";
+ public void setValue(String name, T value) {
+ sdk.nvpairs.setObservationValue(studyId, observationId, name, value);
+ }
+
+ @Override
+ public Optional getValue(String name, Class tClass) {
+ return sdk.nvpairs.getObservationValue(studyId, observationId, name, tClass);
+ }
+
+ @Override
+ public void removeValue(String name) {
+ sdk.nvpairs.removeObservationValue(studyId, observationId, name);
}
@Override
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MorePlatformSDKImpl.java b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MorePlatformSDKImpl.java
index 6ba79efa..8ba7ab12 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MorePlatformSDKImpl.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MorePlatformSDKImpl.java
@@ -8,13 +8,13 @@
*/
package io.redlink.more.studymanager.sdk.scoped;
+import io.redlink.more.studymanager.core.io.SimpleParticipant;
import io.redlink.more.studymanager.core.sdk.MorePlatformSDK;
import io.redlink.more.studymanager.model.Participant;
import io.redlink.more.studymanager.sdk.MoreSDK;
-import java.io.Serializable;
-import java.util.Optional;
import java.util.Set;
+import java.util.stream.Collectors;
public abstract class MorePlatformSDKImpl implements MorePlatformSDK {
@@ -40,25 +40,13 @@ public Integer getStudyGroupId() {
@Override
public Set participantIds(ParticipantFilter filter) {
- Set state =
- (filter == ParticipantFilter.ACTIVE_ONLY ? Set.of(Participant.Status.ACTIVE) : null);
- return sdk.listParticipants(studyId, studyGroupId, state);
+ return this.participants(filter).stream().map(SimpleParticipant::getId).collect(Collectors.toSet());
}
@Override
- public void setValue(String name, T value) {
- sdk.setValue(getIssuer(), name, value);
- }
-
- @Override
- public Optional getValue(String name, Class tClass) {
- return sdk.getValue(getIssuer(), name, tClass);
- }
-
- @Override
- public void removeValue(String name) {
- sdk.removeValue(getIssuer(), name);
+ public Set participants(ParticipantFilter filter) {
+ Set state =
+ (filter == ParticipantFilter.ACTIVE_ONLY ? Set.of(Participant.Status.ACTIVE) : null);
+ return sdk.listParticipants(studyId, studyGroupId, state);
}
-
- public abstract String getIssuer();
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreTriggerSDKImpl.java b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreTriggerSDKImpl.java
index dc4443e9..6700bf38 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreTriggerSDKImpl.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/sdk/scoped/MoreTriggerSDKImpl.java
@@ -14,6 +14,8 @@
import io.redlink.more.studymanager.sdk.MoreSDK;
import org.apache.commons.lang3.NotImplementedException;
+import java.io.Serializable;
+import java.util.Optional;
import java.util.Set;
public class MoreTriggerSDKImpl extends MorePlatformSDKImpl implements MoreTriggerSDK {
@@ -25,6 +27,21 @@ public MoreTriggerSDKImpl(MoreSDK sdk, long studyId, Integer studyGroupId, int i
this.interventionId = interventionId;
}
+ @Override
+ public void setValue(String name, T value) {
+ sdk.nvpairs.setTriggerValue(studyId, interventionId, name, value);
+ }
+
+ @Override
+ public Optional getValue(String name, Class tClass) {
+ return sdk.nvpairs.getTriggerValue(studyId, interventionId, name, tClass);
+ }
+
+ @Override
+ public void removeValue(String name) {
+ sdk.nvpairs.removeTriggerValue(studyId, interventionId, name);
+ }
+
@Override
public String addSchedule(Schedule schedule) {
return sdk.addSchedule(getIssuer(), studyId, studyGroupId, interventionId, schedule);
@@ -49,8 +66,7 @@ public void removeWebhook() {
throw new NotImplementedException();
}
- @Override
- public String getIssuer() {
+ private String getIssuer() {
return studyId + "-" + studyGroupId + '-' + interventionId + "-trigger";
}
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/CalendarService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/CalendarService.java
new file mode 100644
index 00000000..8c2c48b1
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/CalendarService.java
@@ -0,0 +1,163 @@
+package io.redlink.more.studymanager.service;
+
+import io.redlink.more.studymanager.exception.NotFoundException;
+import io.redlink.more.studymanager.model.Intervention;
+import io.redlink.more.studymanager.model.Observation;
+import io.redlink.more.studymanager.model.Participant;
+import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.Trigger;
+import io.redlink.more.studymanager.model.scheduler.Duration;
+import io.redlink.more.studymanager.model.timeline.InterventionTimelineEvent;
+import io.redlink.more.studymanager.model.timeline.ObservationTimelineEvent;
+import io.redlink.more.studymanager.model.timeline.StudyTimeline;
+import io.redlink.more.studymanager.utils.SchedulerUtils;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.Range;
+import org.springframework.stereotype.Service;
+
+
+@Service
+public class CalendarService {
+
+ private final StudyService studyService;
+ private final ObservationService observationService;
+ private final InterventionService interventionService;
+ private final ParticipantService participantService;
+
+ public CalendarService(StudyService studyService, ObservationService observationService, InterventionService interventionService,
+ ParticipantService participantService) {
+ this.studyService = studyService;
+ this.observationService = observationService;
+ this.interventionService = interventionService;
+ this.participantService = participantService;
+ }
+
+ public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer studyGroupId, OffsetDateTime referenceDate, LocalDate from, LocalDate to) {
+ final Study study = studyService.getStudy(studyId, null)
+ .orElseThrow(() -> NotFoundException.Study(studyId));
+ final Range studyRange = Range.of(
+ Objects.requireNonNullElse(study.getStartDate(), study.getPlannedStartDate()),
+ Objects.requireNonNullElse(study.getEndDate(), study.getPlannedEndDate()),
+ LocalDate::compareTo
+ );
+ final Participant participant;
+ if (participantId != null) {
+ participant = Optional.ofNullable(participantService.getParticipant(studyId, participantId))
+ .orElseThrow(() -> NotFoundException.Participant(studyId, participantId));
+ } else {
+ participant = null;
+ }
+
+ /* Priority of Parameters:
+ * participantStart:
+ * (1) referenceDate (if provided by user)
+ * (2) participant.start (if participant is provided & has started)
+ * (3) study.start (if study is started)
+ * (4) study.plannedStart
+ */
+ final Instant participantStart;
+ if (referenceDate != null) {
+ participantStart = referenceDate.toInstant();
+ } else if (participant != null && participant.getStart() != null) {
+ participantStart = participant.getStart();
+ } else {
+ participantStart = studyRange.getMinimum()
+ .atTime(LocalTime.of(9, 0))
+ .atZone(ZoneId.systemDefault())
+ .toInstant();
+ }
+
+ /*
+ * effectiveGroup:
+ * (1) participant.group (if participant is provided)
+ * (2) studyGroupId (if provided by user and participant is NOT provided)
+ * (3) otherwise
+ */
+ final Integer effectiveGroup;
+ if (participant != null) {
+ effectiveGroup = participant.getStudyGroupId();
+ } else {
+ effectiveGroup = studyGroupId;
+ }
+
+ final List observations = observationService.listObservationsForGroup(studyId, effectiveGroup);
+ final List interventions = interventionService.listInterventionsForGroup(studyId, effectiveGroup);
+
+ // Shift the effective study-start if the participant would miss a relative observation
+ final LocalDate firstDayInStudy = SchedulerUtils.alignStartDateToSignupInstant(participantStart, observations);
+
+ // now how long does the study run?
+ final Duration studyDuration = Optional.ofNullable(effectiveGroup)
+ .flatMap(eg -> studyService.getStudyDuration(studyId, eg))
+ .or(() -> studyService.getStudyDuration(studyId))
+ .or(() -> Optional.of(new Duration()
+ .setValue(
+ (int) ChronoUnit.DAYS.between(
+ Objects.requireNonNullElse(study.getStartDate(), study.getPlannedStartDate()),
+ Objects.requireNonNullElse(study.getEndDate(), study.getPlannedEndDate())
+ ) + 1)
+ .setUnit(Duration.Unit.DAY)
+ ))
+ .orElseThrow(() -> NotFoundException.Study(studyId));
+
+ final LocalDate lastDayInStudy = firstDayInStudy
+ .plus(
+ // firstDay / lastDay are *inclusive* bounds, therefor we use the "-1" here
+ Math.max(studyDuration.getValue() - 1, 0),
+ studyDuration.getUnit().toChronoUnit()
+ );
+ // Note: the "lastDayInStudy" *could* be after the "(planned) study end", but that's OK
+
+ final Range effectiveRange = Range.of(
+ firstDayInStudy.atTime(LocalTime.MIN).atZone(ZoneId.systemDefault()).toInstant(),
+ lastDayInStudy.atTime(LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant()
+ );
+ final Range filterWindow = Range.of(
+ from.atTime(LocalTime.MIN).atZone(ZoneId.systemDefault()).toInstant(),
+ to.atTime(LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant()
+ );
+
+ return new StudyTimeline(
+ participantStart,
+ Range.of(firstDayInStudy, lastDayInStudy, LocalDate::compareTo),
+ observations.stream()
+ .flatMap(o -> SchedulerUtils
+ .parseToObservationSchedules(
+ o.getSchedule(), effectiveRange.getMinimum(), effectiveRange.getMaximum()
+ )
+ .stream()
+ // Disabled client-side filter for now...
+ // .filter(filterWindow::isOverlappedBy)
+ .map(e -> ObservationTimelineEvent.fromObservation(o, e.getMinimum(), e.getMaximum()))
+ )
+ .toList(),
+ interventions.stream()
+ .map(intervention -> {
+ Trigger trigger = interventionService.getTriggerByIds(studyId, intervention.getInterventionId());
+ return SchedulerUtils.parseToInterventionSchedules(
+ trigger,
+ effectiveRange.getMinimum(),
+ effectiveRange.getMaximum()
+ )
+ .stream()
+ // Disabled client-side filter for now...
+ // .filter(filterWindow::contains)
+ .map(event -> InterventionTimelineEvent.fromInterventionAndTrigger(intervention, trigger, event))
+ .toList();
+ })
+ .flatMap(List::stream)
+ .collect(Collectors.toList())
+
+ );
+ }
+
+}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/ElasticService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/ElasticService.java
index 72ddb09d..8035caa6 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/ElasticService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/ElasticService.java
@@ -29,6 +29,9 @@
import io.redlink.more.studymanager.model.data.SimpleDataPoint;
import io.redlink.more.studymanager.properties.ElasticProperties;
import io.redlink.more.studymanager.utils.MapperUtils;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -158,6 +161,7 @@ public boolean deleteIndex(Long studyId) {
}
public void removeDataForParticipant(Long studyId, Integer participantId) {
+ if (!indexExists(studyId)) { return; }
try {
DeleteByQueryRequest deleteByQueryRequest = new DeleteByQueryRequest.Builder()
.index(getStudyIdString(studyId))
@@ -170,8 +174,10 @@ public void removeDataForParticipant(Long studyId, Integer participantId) {
.build();
client.deleteByQuery(deleteByQueryRequest);
} catch (IOException | ElasticsearchException e) {
- LOG.warn("Error when deleting participant from elastic index. Error message: ", e);
- throw new RuntimeException(e);
+ handleIndexNotFoundException(e, t -> {
+ LOG.warn("Error when deleting participant from elastic index. Error message: ", e);
+ return new RuntimeException(t);
+ });
}
}
@@ -265,7 +271,10 @@ public List getParticipationData(Long studyId){
}
return participationDataList;
}catch (IOException | ElasticsearchException e) {
- LOG.error("Elastic Query failed", e);
+ if (isElasticIndexNotFound(e)) {
+ return List.of();
+ }
+ LOG.warn("Elastic Query failed", e);
return new ArrayList<>();
}
}
@@ -362,4 +371,32 @@ private Map toData(Map source) {
.forEach(k -> result.put(k.substring(5), source.get(k)));
return result;
}
+
+ private static R handleIndexNotFoundException(T e, Supplier supplier) throws T {
+ return ElasticService.handleIndexNotFoundException(e, supplier, Function.identity());
+ }
+
+ private static R handleIndexNotFoundException(E e, Supplier supplier, Function exceptionTFunction) throws T {
+ if (isElasticIndexNotFound(e)) return supplier.get();
+ throw exceptionTFunction.apply(e);
+ }
+
+ private static boolean isElasticIndexNotFound(Exception e) {
+ if (e instanceof ElasticsearchException ee) {
+ if (Objects.equals(ee.error().type(), "index_not_found_exception")) {
+ LOG.debug("Swallowing Index-Not-Found from Elastic");
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void handleIndexNotFoundException(E e) throws E {
+ handleIndexNotFoundException(e, Function.identity());
+ }
+
+ private static void handleIndexNotFoundException(E e, Function exceptionWrapper) throws T {
+ if (!isElasticIndexNotFound(e))
+ throw exceptionWrapper.apply(e);
+ }
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/FirebaseMessagingService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/FirebaseMessagingService.java
index f9e472ff..089374c6 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/FirebaseMessagingService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/FirebaseMessagingService.java
@@ -96,7 +96,10 @@ private static ApnsConfig getApnsConfig(String apsCategory, apnsPushType type, a
.builder()
.putHeader(apnsPriorityHeader, String.valueOf(priority.value))
.putHeader(apnsPushTypeHeader, type.value)
- .setAps(Aps.builder().setCategory(apsCategory).build())
+ .setAps(Aps.builder()
+ .setCategory(apsCategory)
+ .setMutableContent(true)
+ .build())
.build();
}
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java
index 9505c019..a4273d75 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java
@@ -18,13 +18,12 @@
import org.springframework.core.io.Resource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Scanner;
+import java.util.*;
@Service
public class ImportExportService {
@@ -36,17 +35,20 @@ public class ImportExportService {
private final ObservationService observationService;
private final InterventionService interventionService;
private final StudyGroupService studyGroupService;
+ private final IntegrationService integrationService;
private final ElasticService elasticService;
public ImportExportService(ParticipantService participantService, StudyService studyService, StudyStateService studyStateService,
- ObservationService observationService, InterventionService interventionService, StudyGroupService studyGroupService, ElasticService elasticService) {
+ ObservationService observationService, InterventionService interventionService, StudyGroupService studyGroupService,
+ IntegrationService integrationService, ElasticService elasticService) {
this.participantService = participantService;
this.studyService = studyService;
this.studyStateService = studyStateService;
this.observationService = observationService;
this.interventionService = interventionService;
this.studyGroupService = studyGroupService;
+ this.integrationService = integrationService;
this.elasticService = elasticService;
}
@@ -65,7 +67,7 @@ public void importParticipants(Long studyId, InputStream inputStream) {
boolean isHeader = true;
while (scanner.hasNext()) {
String line = scanner.next();
- if(!isHeader && StringUtils.isNotBlank(line)) {
+ if (!isHeader && StringUtils.isNotBlank(line)) {
participantService.createParticipant(new Participant().setStudyId(studyId).setAlias(line));
} else {
isHeader = false;
@@ -87,9 +89,27 @@ public StudyImportExport exportStudy(Long studyId, User user) {
.setObservations(observationService.listObservations(studyId))
.setInterventions(interventionService.listInterventions(studyId))
.setActions(new HashMap<>())
- .setTriggers(new HashMap<>());
+ .setTriggers(new HashMap<>())
+ .setParticipants(new ArrayList<>())
+ .setIntegrations(new ArrayList<>());
- for(Integer interventionId: export.getInterventions().stream().map(Intervention::getInterventionId).toList()) {
+ export.setParticipants(participantService.listParticipants(studyId)
+ .stream()
+ .sorted(Comparator.comparing(Participant::getParticipantId))
+ .map(participant -> new StudyImportExport.ParticipantInfo(participant.getStudyGroupId()))
+ .toList()
+ );
+
+ export.setIntegrations(
+ export.getObservations().stream()
+ .map(Observation::getObservationId)
+ .flatMap(observationId -> {
+ List tokens = integrationService.getTokens(studyId, observationId);
+ return tokens.stream().map(token -> new IntegrationInfo(token.tokenLabel(), observationId));
+ }).toList()
+ );
+
+ for (Integer interventionId : export.getInterventions().stream().map(Intervention::getInterventionId).toList()) {
export.getActions()
.put(interventionId, interventionService.listActions(studyId, interventionId));
export.getTriggers()
@@ -98,43 +118,41 @@ public StudyImportExport exportStudy(Long studyId, User user) {
return export;
}
+ @Transactional
public Study importStudy(StudyImportExport studyImport, AuthenticatedUser user) {
- Study newStudy = studyService.createStudy(studyImport.getStudy(), user);
- Long studyId = newStudy.getStudyId();
- HashMap studyGroupIds = new HashMap<>();
- HashMap interventionIds = new HashMap<>();
+ final Study newStudy = studyService.createStudy(studyImport.getStudy(), user);
+ final Long studyId = newStudy.getStudyId();
studyImport.getStudyGroups().forEach(studyGroup ->
- studyGroupIds.put(
- studyGroup.getStudyGroupId(),
- studyGroupService.createStudyGroup(studyGroup.setStudyId(studyId)).getStudyGroupId())
+ studyGroupService.importStudyGroup(studyId, studyGroup)
);
- studyImport.getObservations().forEach(observation -> {
- if(observation.getStudyGroupId() != null) {
- observation.setStudyGroupId(studyGroupIds.get(observation.getStudyGroupId()));
- }
- observationService.addObservation(observation.setStudyId(studyId));
- });
- studyImport.getInterventions().forEach(intervention -> {
- if(intervention.getStudyGroupId() != null) {
- intervention.setStudyGroupId(studyGroupIds.get(intervention.getStudyGroupId()));
- }
- interventionIds.put(
- intervention.getInterventionId(),
- interventionService.addIntervention(intervention.setStudyId(studyId)).getInterventionId());
- });
- studyImport.getTriggers().forEach((oldInterventionId, trigger) ->
- interventionService.updateTrigger(studyId, interventionIds.get(oldInterventionId), trigger)
+ studyImport.getObservations().forEach(observation ->
+ observationService.importObservation(studyId, observation)
);
- studyImport.getActions().forEach((oldInterventionId, actionList) ->
- actionList.forEach(action ->
- interventionService.createAction(studyId, interventionIds.get(oldInterventionId), action))
+ studyImport.getInterventions().forEach(intervention ->
+ interventionService.importIntervention(
+ studyId,
+ intervention,
+ studyImport.getTriggers().get(intervention.getInterventionId()),
+ studyImport.getActions().getOrDefault(intervention.getInterventionId(), Collections.emptyList())
+ )
);
+ studyImport.getParticipants().forEach(participant ->
+ participantService.createParticipant(
+ new Participant()
+ .setStudyId(studyId)
+ .setAlias("Participant")
+ .setStudyGroupId(participant.groupId())
+ ));
+ studyImport.getIntegrations().forEach(integration ->
+ integrationService.addToken(studyId, integration.observationId(), integration.name())
+ );
+
return newStudy;
}
public void exportStudyData(ServletOutputStream outputStream, Long studyId) {
- if(studyService.existsStudy(studyId).orElse(false)) {
+ if (studyService.existsStudy(studyId).orElse(false)) {
exportStudyDataAsync(outputStream, studyId);
} else {
throw NotFoundException.Study(studyId);
@@ -143,7 +161,7 @@ public void exportStudyData(ServletOutputStream outputStream, Long studyId) {
@Async
public void exportStudyDataAsync(ServletOutputStream outputStream, Long studyId) {
- try(outputStream) {
+ try (outputStream) {
outputStream.write("[".getBytes(StandardCharsets.UTF_8));
elasticService.exportData(studyId, outputStream);
outputStream.write("]".getBytes(StandardCharsets.UTF_8));
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/IntegrationService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/IntegrationService.java
index d1da3f4e..d33ee6ad 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/IntegrationService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/IntegrationService.java
@@ -70,6 +70,11 @@ public void deleteToken(Long studyId, Integer observationId, Integer tokenId) {
repository.deleteToken(studyId, observationId, tokenId);
}
+ public Optional updateToken(Long studyId, Integer observationId, Integer tokenId, String tokenLabel) {
+ studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED);
+ return repository.updateToken(studyId, observationId, tokenId, tokenLabel);
+ }
+
public void alignIntegrationsWithStudyState(Study study) {
if(study.getStudyState() == Study.Status.CLOSED) {
repository.clearForStudyId(study.getStudyId());
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java
index ab46e9f6..07da6003 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java
@@ -11,11 +11,13 @@
import io.redlink.more.studymanager.core.component.Component;
import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
import io.redlink.more.studymanager.core.factory.ActionFactory;
+import io.redlink.more.studymanager.core.factory.TriggerFactory;
+import io.redlink.more.studymanager.core.properties.ActionProperties;
+import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.core.validation.ConfigurationValidationReport;
import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.exception.NotFoundException;
import io.redlink.more.studymanager.model.Action;
-import io.redlink.more.studymanager.core.factory.TriggerFactory;
import io.redlink.more.studymanager.model.Intervention;
import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.model.Trigger;
@@ -23,20 +25,19 @@
import io.redlink.more.studymanager.repository.StudyRepository;
import io.redlink.more.studymanager.sdk.MoreSDK;
import io.redlink.more.studymanager.utils.LoggingUtils;
-
import java.text.ParseException;
-
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
import org.quartz.CronExpression;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
-
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
+import org.springframework.transaction.annotation.Transactional;
@Service
public class InterventionService {
@@ -69,10 +70,35 @@ public Intervention addIntervention(Intervention intervention) {
return repository.insert(intervention);
}
+ @Transactional
+ public Intervention importIntervention(Long studyId, Intervention intervention, Trigger trigger, List actions) {
+ Intervention imported = repository.importIntervention(studyId, intervention);
+
+ {
+ Trigger validated = validateTrigger(trigger);
+ TriggerFactory factory = factory(validated);
+ 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);
+ validated.setProperties((ActionProperties) factory.preImport(validated.getProperties()));
+ repository.importAction(studyId, imported.getInterventionId(), validateAction(validated));
+ });
+
+ return imported;
+ }
+
public List listInterventions(Long studyId) {
return repository.listInterventions(studyId);
}
+ public List listInterventionsForGroup(Long studyId, Integer groupId) {
+ return repository.listInterventionsForGroup(studyId, groupId);
+ }
+
public Intervention getIntervention(Long studyId, Integer interventionId) {
return repository.getByIds(studyId, interventionId);
}
@@ -131,7 +157,7 @@ public void onStartUp() {
}
public void alignInterventionsWithStudyState(Study study) {
- if (study.getStudyState() == Study.Status.ACTIVE) {
+ if (EnumSet.of(Study.Status.ACTIVE, Study.Status.PREVIEW).contains(study.getStudyState())) {
activateInterventionsFor(study);
} else {
deactivateInterventionsFor(study);
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java
index eb0b7add..b2f5b62f 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java
@@ -11,12 +11,14 @@
import io.redlink.more.studymanager.core.component.Component;
import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
import io.redlink.more.studymanager.core.factory.ObservationFactory;
+import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.exception.NotFoundException;
import io.redlink.more.studymanager.model.Observation;
import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.repository.ObservationRepository;
import io.redlink.more.studymanager.sdk.MoreSDK;
+import java.util.EnumSet;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -47,6 +49,16 @@ public Observation addObservation(Observation observation) {
return repository.insert(validate(observation));
}
+ public Observation importObservation(Long studyId, Observation observation) {
+ final ObservationFactory factory = factory(observation);
+ if (factory == null) {
+ throw NotFoundException.ObservationFactory(observation.getType());
+ }
+ ObservationProperties props = (ObservationProperties) factory.preImport(observation.getProperties());
+ observation.setProperties(props);
+ return repository.doImport(studyId, observation);
+ }
+
public void deleteObservation(Long studyId, Integer observationId) {
studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED);
repository.deleteObservation(studyId, observationId);
@@ -64,13 +76,17 @@ public List listObservations(Long studyId) {
return repository.listObservations(studyId);
}
+ public List listObservationsForGroup(Long studyId, Integer groupId) {
+ return repository.listObservationsForGroup(studyId, groupId);
+ }
+
public Observation updateObservation(Observation observation) {
studyStateService.assertStudyNotInState(observation.getStudyId(), Study.Status.CLOSED);
return repository.updateObservation(validate(observation));
}
public void alignObservationsWithStudyState(Study study){
- if(study.getStudyState() == Study.Status.ACTIVE)
+ if (EnumSet.of(Study.Status.ACTIVE, Study.Status.PREVIEW).contains(study.getStudyState()))
activateObservationsFor(study);
else deactivateObservationsFor(study);
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/ParticipantService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/ParticipantService.java
index c1e8b7ee..af812635 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/ParticipantService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/ParticipantService.java
@@ -15,9 +15,9 @@
import java.util.EnumSet;
import java.util.List;
-import java.util.Optional;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import static io.redlink.more.studymanager.model.Participant.Status.*;
@@ -45,6 +45,10 @@ public List listParticipants(Long studyId) {
return participantRepository.listParticipants(studyId);
}
+ public List listParticipantsForClosing() {
+ return participantRepository.listParticipantsForClosing();
+ }
+
public Participant getParticipant(Long studyId, Integer participantId) {
return participantRepository.getByIds(studyId, participantId);
}
@@ -62,10 +66,14 @@ public Participant updateParticipant(Participant participant) {
return participantRepository.update(participant);
}
+ @Transactional
public void alignParticipantsWithStudyState(Study study) {
- if (study.getStudyState() == Study.Status.CLOSED) {
+ if (EnumSet.of(Study.Status.CLOSED).contains(study.getStudyState())) {
participantRepository.cleanupParticipants(study.getStudyId());
}
+ if (EnumSet.of(Study.Status.DRAFT).contains(study.getStudyState())) {
+ participantRepository.resetParticipants(study.getStudyId(), RandomTokenGenerator::generate);
+ }
}
public void setStatus(Long studyId, Integer participantId, Participant.Status status) {
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/PushNotificationService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/PushNotificationService.java
index ce87fd41..e93b2921 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/PushNotificationService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/PushNotificationService.java
@@ -11,7 +11,9 @@
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.MessagingErrorCode;
import io.redlink.more.studymanager.model.Notification;
+import io.redlink.more.studymanager.model.Participant;
import io.redlink.more.studymanager.model.PushNotificationsToken;
+import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.repository.NotificationRepository;
import io.redlink.more.studymanager.repository.PushNotificationTokenRepository;
@@ -86,4 +88,26 @@ public boolean sendPushNotification(long studyID, int participantId, String titl
return false;
}
}
+
+ public void sendStudyStateUpdate(Participant participant, Study.Status oldState, Study.Status newState) {
+ sendPushNotification(
+ participant.getStudyId(),
+ participant.getParticipantId(),
+ "Your Study has a new update",
+ "Your study was updated. For more information, please launch the app!",
+ Map.of("key", "STUDY_STATE_CHANGED",
+ "oldState", toAppState(oldState),
+ "newState", toAppState(newState))
+ );
+ }
+
+ private static String toAppState(Study.Status state) {
+ // Translate the study-states to states the app also knows:
+ return (switch (state) {
+ case ACTIVE, PREVIEW -> Study.Status.ACTIVE;
+ case PAUSED, PAUSED_PREVIEW -> Study.Status.PAUSED;
+ default -> Study.Status.CLOSED;
+ }).getValue();
+ }
+
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyGroupService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyGroupService.java
index 7c9b5b94..b3623086 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyGroupService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyGroupService.java
@@ -33,6 +33,10 @@ public StudyGroup createStudyGroup(StudyGroup studyGroup) {
return this.repository.insert(studyGroup);
}
+ public StudyGroup importStudyGroup(Long studyId, StudyGroup studyGroup) {
+ return this.repository.doImport(studyId, studyGroup);
+ }
+
public List listStudyGroups(long studyId) {
return this.repository.listStudyGroupsOrderedByStudyGroupIdAsc(studyId);
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyService.java
index 15d20ad3..b520dc83 100644
--- a/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyService.java
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/StudyService.java
@@ -11,21 +11,46 @@
import io.redlink.more.studymanager.exception.BadRequestException;
import io.redlink.more.studymanager.exception.DataConstraintException;
import io.redlink.more.studymanager.exception.NotFoundException;
-import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.MoreUser;
+import io.redlink.more.studymanager.model.Participant;
+import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.StudyGroup;
+import io.redlink.more.studymanager.model.StudyRole;
+import io.redlink.more.studymanager.model.StudyUserRoles;
+import io.redlink.more.studymanager.model.User;
+import io.redlink.more.studymanager.model.scheduler.Duration;
import io.redlink.more.studymanager.repository.StudyAclRepository;
+import io.redlink.more.studymanager.repository.StudyGroupRepository;
import io.redlink.more.studymanager.repository.StudyRepository;
import io.redlink.more.studymanager.repository.UserRepository;
+
+import java.time.temporal.ChronoUnit;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
-
-import java.util.*;
+import org.springframework.transaction.annotation.Transactional;
@Service
public class StudyService {
private static final Logger log = LoggerFactory.getLogger(StudyService.class);
+
+ static final Map> VALID_STUDY_TRANSITIONS = Map.of(
+ Study.Status.DRAFT, EnumSet.of(Study.Status.PREVIEW, Study.Status.ACTIVE),
+ Study.Status.PREVIEW, EnumSet.of(Study.Status.PAUSED_PREVIEW, Study.Status.DRAFT),
+ Study.Status.PAUSED_PREVIEW, EnumSet.of(Study.Status.PREVIEW, Study.Status.DRAFT),
+ Study.Status.ACTIVE, EnumSet.of(Study.Status.PAUSED, Study.Status.CLOSED),
+ Study.Status.PAUSED, EnumSet.of(Study.Status.ACTIVE, Study.Status.CLOSED)
+ );
+
private final StudyRepository studyRepository;
private final StudyAclRepository aclRepository;
private final UserRepository userRepo;
@@ -37,11 +62,12 @@ public class StudyService {
private final ElasticService elasticService;
private final PushNotificationService pushNotificationService;
+ private final StudyGroupRepository studyGroupRepository;
public StudyService(StudyRepository studyRepository, StudyAclRepository aclRepository, UserRepository userRepo,
StudyStateService studyStateService, InterventionService interventionService, ObservationService observationService,
- ParticipantService participantService, IntegrationService integrationService, ElasticService elasticService, PushNotificationService pushNotificationService) {
+ ParticipantService participantService, IntegrationService integrationService, ElasticService elasticService, PushNotificationService pushNotificationService, StudyGroupRepository studyGroupRepository) {
this.studyRepository = studyRepository;
this.aclRepository = aclRepository;
this.userRepo = userRepo;
@@ -52,6 +78,7 @@ public StudyService(StudyRepository studyRepository, StudyAclRepository aclRepos
this.integrationService = integrationService;
this.elasticService = elasticService;
this.pushNotificationService = pushNotificationService;
+ this.studyGroupRepository = studyGroupRepository;
}
public Study createStudy(Study study, User currentUser) {
@@ -90,44 +117,51 @@ public void deleteStudy(Long studyId) {
elasticService.deleteIndex(studyId);
}
- public void setStatus(Long studyId, Study.Status status, User user) {
- Study study = getStudy(studyId, user)
+ @Transactional
+ public void setStatus(Long studyId, Study.Status newState, User user) {
+ final Study study = getStudy(studyId, user)
.orElseThrow(() -> NotFoundException.Study(studyId));
- if (status.equals(Study.Status.DRAFT)) {
- throw BadRequestException.StateChange(study.getStudyState(), Study.Status.DRAFT);
- }
- if (study.getStudyState().equals(Study.Status.CLOSED)) {
- throw BadRequestException.StateChange(Study.Status.CLOSED, status);
- }
- if (study.getStudyState().equals(status)) {
- throw BadRequestException.StateChange(study.getStudyState(), status);
+ final Study.Status oldState = study.getStudyState();
+
+ /* Validate the transition */
+ if (!VALID_STUDY_TRANSITIONS.getOrDefault(oldState, EnumSet.noneOf(Study.Status.class)).contains(newState)) {
+ throw BadRequestException.StateChange(oldState, newState);
}
- Study.Status oldState = study.getStudyState();
-
- studyRepository.setStateById(studyId, status);
- studyRepository.getById(studyId).ifPresent(s -> {
- try {
- alignWithStudyState(s);
- participantService.listParticipants(studyId).forEach(participant -> {
- pushNotificationService.sendPushNotification(
- studyId,
- participant.getParticipantId(),
- "Your Study has a new update",
- "Your study was updated. For more information, please launch the app!",
- Map.of("key", "STUDY_STATE_CHANGED",
- "oldState", oldState.getValue(),
- "newState", s.getStudyState().getValue())
- );
+ studyRepository.setStateById(studyId, newState)
+ .ifPresent(s -> {
+ try {
+ alignWithStudyState(s);
+ participantService.listParticipants(studyId).forEach(participant ->
+ pushNotificationService.sendStudyStateUpdate(participant, oldState, s.getStudyState())
+ );
+ participantService.alignParticipantsWithStudyState(s);
+ if (s.getStudyState() == Study.Status.DRAFT) {
+ log.info("Study {} transitioned back to {}, dropping collected observation data", study.getStudyId(), s.getStudyState());
+ elasticService.deleteIndex(s.getStudyId());
+ }
+ } catch (Exception e) {
+ log.warn("Could not set new state for study id {}; old state: {}; new state: {}", studyId, oldState.getValue(), s.getStudyState().getValue());
+ //ROLLBACK
+ studyRepository.setStateById(studyId, oldState);
+ studyRepository.getById(studyId).ifPresent(this::alignWithStudyState);
+ throw new BadRequestException("Study cannot be initialized", e);
+ }
});
- participantService.alignParticipantsWithStudyState(s);
- } catch (Exception e) {
- log.warn("Could not set new state for study id {}; old state: {}; new state: {}", studyId, oldState.getValue(), s.getStudyState().getValue());
- //ROLLBACK
- studyRepository.setStateById(studyId, oldState);
- studyRepository.getById(studyId).ifPresent(this::alignWithStudyState);
- throw new BadRequestException("Study cannot be initialized");
- }
+ }
+
+ // every minute
+ @Scheduled(cron = "0 * * * * ?")
+ public void closeParticipationsForStudiesWithDurations() {
+ List participantsToClose = participantService.listParticipantsForClosing();
+ log.debug("Selected {} participants to close", participantsToClose.size());
+ participantsToClose.forEach(participant -> {
+ pushNotificationService.sendStudyStateUpdate(
+ participant, Study.Status.ACTIVE, Study.Status.CLOSED
+ );
+ participantService.setStatus(
+ participant.getStudyId(), participant.getParticipantId(), Participant.Status.LOCKED
+ );
});
}
@@ -167,4 +201,21 @@ public Optional getRolesForStudy(Long studyId, String userId) {
)
);
}
+
+ public Optional getStudyDuration(Long studyId) {
+ return studyRepository.getById(studyId)
+ .map(Study::getDuration);
+ }
+
+ public Optional getStudyDuration(Long studyId, Integer studyGroupId) {
+ final Optional group = Optional.ofNullable(studyGroupRepository.getByIds(studyId, studyGroupId));
+ // If there's no such group, return empty() here...
+ if (group.isEmpty()) return Optional.empty();
+
+ return group
+ // Get the groups duration...
+ .map(StudyGroup::getDuration)
+ // ... of fallback to the study-duration if not set.
+ .or(() -> getStudyDuration(studyId));
+ }
}
diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/utils/SchedulerUtils.java b/studymanager/src/main/java/io/redlink/more/studymanager/utils/SchedulerUtils.java
new file mode 100644
index 00000000..d772a74b
--- /dev/null
+++ b/studymanager/src/main/java/io/redlink/more/studymanager/utils/SchedulerUtils.java
@@ -0,0 +1,237 @@
+package io.redlink.more.studymanager.utils;
+
+import biweekly.component.VEvent;
+import biweekly.util.DayOfWeek;
+import biweekly.util.Frequency;
+import biweekly.util.Recurrence;
+import biweekly.util.com.google.ical.compat.javautil.DateIterator;
+import io.redlink.more.studymanager.model.Observation;
+import io.redlink.more.studymanager.model.Trigger;
+import io.redlink.more.studymanager.model.scheduler.*;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.Range;
+import org.quartz.CronExpression;
+
+import java.sql.Date;
+import java.text.ParseException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+
+public final class SchedulerUtils {
+
+ public static List> parseToObservationSchedulesForRelativeEvent(
+ RelativeEvent event, Instant start, Instant maxEnd) {
+
+ final List> events = new ArrayList<>();
+
+ Range currentEvt = Range.of(
+ toInstantFrom(event.getDtstart(), start),
+ toInstantFrom(event.getDtend(), start)
+ );
+
+ if (event.getRrrule() != null) {
+ RelativeRecurrenceRule rrule = event.getRrrule();
+ Instant maxEndOfRule = currentEvt.getMaximum().plus(rrule.getEndAfter().getValue(), rrule.getEndAfter().getUnit().toChronoUnit());
+ maxEnd = maxEnd.isBefore(maxEndOfRule) ? maxEnd : maxEndOfRule;
+ long durationInMs = currentEvt.getMaximum().toEpochMilli() - currentEvt.getMinimum().toEpochMilli();
+
+ while (currentEvt.getMaximum().isBefore(maxEnd)) {
+ events.add(currentEvt);
+ Instant estart = currentEvt.getMinimum().plus(rrule.getFrequency().getValue(), rrule.getFrequency().getUnit().toChronoUnit());
+ currentEvt = Range.of(estart, estart.plusMillis(durationInMs));
+ }
+ } else {
+ events.add(currentEvt);
+ }
+
+ return List.copyOf(events);
+ }
+
+ private static Instant toInstantFrom(RelativeDate date, Instant start) {
+ return start.atZone(ZoneId.systemDefault())
+ // FIXME: Hidden Offset-Correction
+ // Offset is 1-based, therefor we must "-1" here
+ // (fist day: 1, second day: 2, ... )
+ .plus(date.getOffset().getValue() - 1, date.getOffset().getUnit().toChronoUnit())
+ .with(date.getTime())
+ .toInstant();
+ }
+
+ public static List> parseToObservationSchedulesForEvent(Event event, Instant start, Instant end) {
+ List> observationSchedules = new ArrayList<>();
+ if (event.getDateStart() != null && event.getDateEnd() != null) {
+ VEvent iCalEvent = parseToICalEvent(event, end);
+ long eventDuration = getEventTime(event);
+ DateIterator it = iCalEvent.getDateIterator(TimeZone.getDefault());
+ while (it.hasNext()) {
+ Instant ostart = it.next().toInstant();
+ Instant oend = ostart.plus(eventDuration, ChronoUnit.SECONDS);
+ if (ostart.isBefore(end) && oend.isAfter(start)) {
+ observationSchedules.add(Range.of(ostart, oend));
+ }
+ }
+ }
+ // TODO edge cases if calculated days are not consecutive (e.g. first weekend -> first of month is a sunday)
+ return List.copyOf(observationSchedules);
+ }
+
+ public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, Instant start, Instant end) {
+ if (scheduleEvent == null) return Collections.emptyList();
+ if (scheduleEvent instanceof Event event) {
+ return parseToObservationSchedulesForEvent(event, start, end);
+ } else if (scheduleEvent instanceof RelativeEvent relativeEvent) {
+ return parseToObservationSchedulesForRelativeEvent(relativeEvent, start, end);
+ } else {
+ return Collections.emptyList();
+ }
+ }
+
+ public static LocalDate alignStartDateToSignupInstant(final Instant signup, List observations) {
+ return LocalDate.ofInstant(observations.stream()
+ // All the observation-schedules
+ .map(Observation::getSchedule)
+ // ... the relative ones
+ .filter(e -> RelativeEvent.TYPE.equals(e.getType()))
+ .map(e -> (RelativeEvent) e)
+ // all the relative schedules END
+ .map(RelativeEvent::getDtend)
+ // Get the EARLIEST relative schedule end
+ .min(Comparator.comparing(RelativeDate::getOffset, io.redlink.more.studymanager.model.scheduler.Duration.DURATION_COMPARATOR))
+ // Convert the relative schedule end to the actual instant
+ .map(rd -> LocalDate.ofInstant(
+ toInstantFrom(rd, signup), ZoneId.systemDefault()
+ )
+ .atTime(rd.getTime())
+ .atZone(ZoneId.systemDefault())
+ .toInstant()
+ )
+ // has the first relative schedule end already passed?
+ .filter(end -> end.isBefore(signup))
+ // then we start "tomorrow"
+ .map(i -> signup.atZone(ZoneId.systemDefault()).plusDays(1).toInstant())
+ // otherwise we start "now"
+ .orElse(signup),
+ ZoneId.systemDefault()
+ );
+ }
+
+ public static List parseToInterventionSchedules(Trigger trigger, Instant start, Instant end) {
+ if(trigger == null) return Collections.emptyList();
+ if(Objects.equals(trigger.getType(), "relative-time-trigger")) {
+ return parseToInterventionSchedulesForRelativeTrigger(trigger, start, end);
+ } else if(Objects.equals(trigger.getType(), "scheduled-trigger")) {
+ return parseToInterventionSchedulesForScheduledTrigger(trigger, start, end);
+ } else {
+ return Collections.emptyList();
+ }
+ }
+
+ private static List parseToInterventionSchedulesForRelativeTrigger(Trigger trigger, Instant start, Instant end) {
+ return Stream.of(toInstantFrom(
+ new RelativeDate()
+ .setTime(LocalTime.of(trigger.getProperties().getInt("hour"), 0))
+ .setOffset(new Duration().setValue(trigger.getProperties().getInt("day")).setUnit(Duration.Unit.DAY)),
+ start
+ ))
+ .filter(i -> i.isBefore(end))
+ .toList();
+ }
+
+ private static List parseToInterventionSchedulesForScheduledTrigger(Trigger trigger, Instant start, Instant end) {
+ List events = new ArrayList<>();
+ String cronString = trigger.getProperties().get("cronSchedule").toString();
+ if (CronExpression.isValidExpression(cronString)) {
+ try {
+ CronExpression cronExpression = new CronExpression(cronString);
+ cronExpression.setTimeZone(TimeZone.getTimeZone(ZoneId.systemDefault()));
+ Instant currentDate = cronExpression.getNextValidTimeAfter(Date.from(start)).toInstant();
+ while (currentDate.isBefore(end)) {
+ events.add(currentDate);
+ currentDate = cronExpression.getNextValidTimeAfter(Date.from(currentDate)).toInstant();
+ }
+ } catch (ParseException ignore) {}
+ }
+ return List.copyOf(events);
+ }
+
+ public static Instant shiftStartIfObservationAlreadyEnded(Instant start, List observations) {
+ // returns start date, if now event ends before, otherwise start date + 1 day
+ return observations.stream()
+ .map(Observation::getSchedule)
+ .filter(scheduleEvent -> scheduleEvent.getType().equals(RelativeEvent.TYPE))
+ .map(r -> ((RelativeEvent) r).getDtend())
+ .filter(relativeDate -> relativeDate.getOffset().getValue() == 1)
+ .map(relativeDate -> start.atZone(ZoneId.systemDefault()).withHour(relativeDate.getHours()).withMinute(relativeDate.getMinutes()).withSecond(0).withNano(0).toInstant())
+ .filter(instant -> instant.isBefore(start))
+ .map(instant -> start.atZone(ZoneId.systemDefault()).withHour(0).withMinute(0).plusDays(1).toInstant())
+ .findFirst()
+ .orElse(start);
+ }
+
+ private static long getEventTime(Event event) {
+ return java.time.Duration.between(event.getDateStart(), event.getDateEnd()).getSeconds();
+ }
+
+ private static VEvent parseToICalEvent(Event event, Instant fallBackEnd) {
+ VEvent iCalEvent = new VEvent();
+ iCalEvent.setDateStart(Date.from(event.getDateStart()));
+ iCalEvent.setDateEnd(Date.from(event.getDateEnd()));
+
+ RecurrenceRule eventRecurrence = event.getRRule();
+ if (eventRecurrence != null) {
+ Recurrence.Builder recurBuilder = new Recurrence.Builder(Frequency.valueOf(eventRecurrence.getFreq()));
+
+ setUntil(recurBuilder, Objects.requireNonNullElse(eventRecurrence.getUntil(), fallBackEnd));
+ setCount(recurBuilder, eventRecurrence.getCount());
+ setInterval(recurBuilder, eventRecurrence.getInterval());
+ setByDay(recurBuilder, eventRecurrence.getByDay(), eventRecurrence.getBySetPos());
+ setByHour(recurBuilder, eventRecurrence.getFreq(), event.getDateStart().atZone(TimeZone.getDefault().toZoneId()).getHour());
+ setByMinute(recurBuilder, event.getDateStart().atZone(TimeZone.getDefault().toZoneId()).getMinute());
+ setByMonth(recurBuilder, eventRecurrence.getByMonth());
+ setByMonthDay(recurBuilder, eventRecurrence.getByMonthDay());
+
+ iCalEvent.setRecurrenceRule(new biweekly.property.RecurrenceRule(recurBuilder.build()));
+ }
+ return iCalEvent;
+ }
+
+ private static void setByMinute(Recurrence.Builder builder, Integer minute) {
+ if (minute != null) builder.byMinute(minute);
+ }
+
+ private static void setByHour(Recurrence.Builder builder, String freq, Integer hour) {
+ if (hour != null && !Objects.equals(freq, "HOURLY")) builder.byHour(hour);
+ }
+
+ private static void setUntil(Recurrence.Builder builder, Instant until) {
+ if (until != null) builder.until(Date.from(until));
+ }
+
+ private static void setCount(Recurrence.Builder builder, Integer count) {
+ if (count != null) builder.count(count);
+ }
+
+ private static void setInterval(Recurrence.Builder builder, Integer interval) {
+ if (interval != null) builder.interval(interval);
+ }
+
+ private static void setByDay(Recurrence.Builder builder, List byDay, Integer bySetPos) {
+ if (byDay != null && bySetPos == null)
+ builder.byDay(byDay.stream().map(DayOfWeek::valueOfAbbr).toList());
+ if (byDay != null && bySetPos != null)
+ byDay.forEach(day -> builder.byDay(bySetPos, DayOfWeek.valueOfAbbr(day)));
+
+ }
+
+ private static void setByMonth(Recurrence.Builder builder, Integer byMonth) {
+ if (byMonth != null) builder.byMonth(byMonth);
+ }
+
+ private static void setByMonthDay(Recurrence.Builder builder, Integer byMonthDay) {
+ if (byMonthDay != null) builder.byMonthDay(byMonthDay);
+ }
+}
diff --git a/studymanager/src/main/resources/application.yaml b/studymanager/src/main/resources/application.yaml
index 4cc7df79..c945047c 100644
--- a/studymanager/src/main/resources/application.yaml
+++ b/studymanager/src/main/resources/application.yaml
@@ -83,6 +83,8 @@ management:
show-components: always
more:
+ gateway:
+ base-url: '${GATEWAY_BASE_URL:http://localhost:8085}'
components:
lime-survey-observation:
username: '${LIME_ADMIN_USER:more-admin}'
diff --git a/studymanager/src/main/resources/db/migration/V1_12_0__add_duration_to_study_and_study_group.sql b/studymanager/src/main/resources/db/migration/V1_12_0__add_duration_to_study_and_study_group.sql
new file mode 100644
index 00000000..f82b42d3
--- /dev/null
+++ b/studymanager/src/main/resources/db/migration/V1_12_0__add_duration_to_study_and_study_group.sql
@@ -0,0 +1,5 @@
+ALTER TABLE studies
+ ADD COLUMN duration JSONB;
+
+ALTER TABLE study_groups
+ ADD COLUMN duration JSONB;
diff --git a/studymanager/src/main/resources/db/migration/V1_13_0__add_start_timestamp_to_participant.sql b/studymanager/src/main/resources/db/migration/V1_13_0__add_start_timestamp_to_participant.sql
new file mode 100644
index 00000000..e184a0b7
--- /dev/null
+++ b/studymanager/src/main/resources/db/migration/V1_13_0__add_start_timestamp_to_participant.sql
@@ -0,0 +1,2 @@
+ALTER TABLE participants
+ ADD COLUMN start TIMESTAMP;
diff --git a/studymanager/src/main/resources/db/migration/V1_14_0__add_new_nvpairs_table.sql b/studymanager/src/main/resources/db/migration/V1_14_0__add_new_nvpairs_table.sql
new file mode 100644
index 00000000..56b1954a
--- /dev/null
+++ b/studymanager/src/main/resources/db/migration/V1_14_0__add_new_nvpairs_table.sql
@@ -0,0 +1,77 @@
+CREATE TABLE IF NOT EXISTS nvpairs_observations (
+ study_id BIGINT NOT NULL,
+ observation_id INT,
+ name VARCHAR,
+ value bytea NOT NULL,
+
+ PRIMARY KEY (study_id, observation_id, name),
+ FOREIGN KEY (study_id, observation_id) REFERENCES observations(study_id, observation_id) ON DELETE CASCADE
+);
+
+WITH legacy AS (
+ SELECT
+ name,
+ value,
+ CAST(substring(issuer, '^\d+') AS BIGINT) AS study_id,
+ CAST(substring(replace(issuer, 'null', '0'), '^\d+-\d+-(\d+)') AS INT) AS observation_id
+ FROM nvpairs
+ WHERE issuer LIKE '%_observation'
+)
+INSERT INTO nvpairs_observations (name, value, study_id, observation_id)
+SELECT legacy.* FROM legacy
+ INNER JOIN observations ON (legacy.study_id = observations.study_id AND legacy.observation_id = observations.observation_id)
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS nvpairs_triggers (
+ study_id BIGINT NOT NULL,
+ intervention_id INT,
+ name VARCHAR,
+ value bytea NOT NULL,
+
+ PRIMARY KEY (study_id, intervention_id, name),
+ FOREIGN KEY (study_id, intervention_id) REFERENCES interventions(study_id, intervention_id) ON DELETE CASCADE,
+ FOREIGN KEY (study_id, intervention_id) REFERENCES triggers(study_id, intervention_id) ON DELETE CASCADE
+);
+
+WITH legacy AS (
+ SELECT
+ name,
+ value,
+ CAST(substring(issuer, '^\d+') AS BIGINT) AS study_id,
+ CAST(substring(replace(issuer, 'null', '0'), '^\d+-\d+-(\d+)') AS INT) AS intervention_id
+ FROM nvpairs
+ WHERE issuer LIKE '%_trigger'
+)
+INSERT INTO nvpairs_triggers (name, value, study_id, intervention_id)
+SELECT legacy.* FROM legacy
+ INNER JOIN triggers ON (legacy.study_id = triggers.study_id AND legacy.intervention_id = triggers.intervention_id)
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS nvpairs_actions (
+ study_id BIGINT NOT NULL,
+ intervention_id INT,
+ action_id INT,
+ name VARCHAR,
+ value bytea NOT NULL,
+
+ PRIMARY KEY (study_id, intervention_id, action_id, name),
+ FOREIGN KEY (study_id, intervention_id) REFERENCES interventions(study_id, intervention_id) ON DELETE CASCADE,
+ FOREIGN KEY (study_id, intervention_id, action_id) REFERENCES actions(study_id, intervention_id, action_id) ON DELETE CASCADE
+);
+
+WITH legacy AS (
+ SELECT
+ name,
+ value,
+ CAST(substring(issuer, '^\d+') AS BIGINT) AS study_id,
+ CAST(substring(replace(issuer, 'null', '0'), '^\d+-\d+-(\d+)') AS INT) AS intervention_id,
+ CAST(substring(replace(issuer, 'null', '0'), '^\d+-\d+-\d+-(\d+)') AS INT) AS action_id
+ FROM nvpairs
+ WHERE issuer LIKE '%_action'
+)
+INSERT INTO nvpairs_actions (name, value, study_id, intervention_id, action_id)
+SELECT legacy.* FROM legacy
+ INNER JOIN actions ON (legacy.study_id = actions.study_id AND legacy.intervention_id = actions.intervention_id AND legacy.action_id = actions.action_id)
+ON CONFLICT DO NOTHING;
+
+DROP TABLE nvpairs;
diff --git a/studymanager/src/main/resources/db/migration/V1_15_0__study_preview.sql b/studymanager/src/main/resources/db/migration/V1_15_0__study_preview.sql
new file mode 100644
index 00000000..8957ac83
--- /dev/null
+++ b/studymanager/src/main/resources/db/migration/V1_15_0__study_preview.sql
@@ -0,0 +1,2 @@
+ALTER TYPE study_state ADD VALUE 'preview';
+ALTER TYPE study_state ADD VALUE 'paused-preview';
diff --git a/studymanager/src/main/resources/db/migration/V1_15_1__extend_gateway_for_preview.sql b/studymanager/src/main/resources/db/migration/V1_15_1__extend_gateway_for_preview.sql
new file mode 100644
index 00000000..2cc80424
--- /dev/null
+++ b/studymanager/src/main/resources/db/migration/V1_15_1__extend_gateway_for_preview.sql
@@ -0,0 +1,9 @@
+-- Update gateway-view to consider preview
+CREATE OR REPLACE VIEW auth_routing_info (api_id, api_secret, study_id, participant_id, study_group_id, study_is_active) AS
+SELECT api_credentials.*, pt.study_group_id, s.status IN ('active', 'preview')
+FROM api_credentials
+ INNER JOIN participants pt
+ ON (api_credentials.study_id = pt.study_id and api_credentials.participant_id = pt.participant_id)
+ INNER JOIN studies s
+ ON (api_credentials.study_id = s.study_id)
+;
diff --git a/studymanager/src/main/resources/db/migration/V1_15_2__add_participant_active_to_gateway_view.sql b/studymanager/src/main/resources/db/migration/V1_15_2__add_participant_active_to_gateway_view.sql
new file mode 100644
index 00000000..0fe7b32d
--- /dev/null
+++ b/studymanager/src/main/resources/db/migration/V1_15_2__add_participant_active_to_gateway_view.sql
@@ -0,0 +1,11 @@
+-- Update gateway-view to check the participant-state
+CREATE OR REPLACE VIEW auth_routing_info (api_id, api_secret, study_id, participant_id, study_group_id, study_is_active, participant_is_active) AS
+SELECT api_credentials.*, pt.study_group_id,
+ s.status IN ('active', 'preview'),
+ pt.status IN ('active')
+FROM api_credentials
+ INNER JOIN participants pt
+ ON (api_credentials.study_id = pt.study_id and api_credentials.participant_id = pt.participant_id)
+ INNER JOIN studies s
+ ON (api_credentials.study_id = s.study_id)
+;
diff --git a/studymanager/src/main/resources/openapi/Event.yaml b/studymanager/src/main/resources/openapi/Event.yaml
index ff98614c..3a003bfb 100644
--- a/studymanager/src/main/resources/openapi/Event.yaml
+++ b/studymanager/src/main/resources/openapi/Event.yaml
@@ -4,11 +4,16 @@ info:
version: "1.0"
description: see https://www.rfc-editor.org/rfc/rfc8984.html#name-recurrence-properties
+paths: {}
+
components:
schemas:
Event:
type: object
properties:
+ type:
+ type: string
+ default: Event
dtstart:
type: string
format: date-time
diff --git a/studymanager/src/main/resources/openapi/RelativeEvent.yaml b/studymanager/src/main/resources/openapi/RelativeEvent.yaml
new file mode 100644
index 00000000..27ef604d
--- /dev/null
+++ b/studymanager/src/main/resources/openapi/RelativeEvent.yaml
@@ -0,0 +1,65 @@
+openapi: "3.0.3"
+info:
+ title: TimeRange Model for Relative Events
+ version: "1.0"
+
+paths: {}
+
+components:
+ schemas:
+ Duration:
+ type: object
+ description: A duration of time
+ properties:
+ value:
+ type: integer
+ description: number of units
+ unit:
+ type: string
+ description: unit of time
+ enum:
+ - MINUTE
+ - HOUR
+ - DAY
+ RelativeDate:
+ type: object
+ description: A date relative to a specific base date (e.g study start)
+ properties:
+ offset:
+ $ref: '#/components/schemas/Duration'
+ time:
+ type: string
+ format: time
+ description: Follows ISO 8601 format for time
+ RelativeRecurrenceRule:
+ type: object
+ description: A recurrence rule relative to dtstart
+ properties:
+ frequency:
+ $ref: '#/components/schemas/Duration'
+ description: How often to repeat
+ endAfter:
+ $ref: '#/components/schemas/Duration'
+ description: How long to repeat
+
+ RelativeEvent:
+ type: object
+ description: An event that occurs at a relative time
+ required:
+ - type
+ - dtstart
+ - dtend
+ properties:
+ type:
+ type: string
+ default: RelativeEvent
+ dtstart:
+ $ref: '#/components/schemas/RelativeDate'
+ description: When the event starts
+ dtend:
+ $ref: '#/components/schemas/RelativeDate'
+ description: When the event ends
+ rrrule:
+ $ref: '#/components/schemas/RelativeRecurrenceRule'
+ description: How to repeat the event
+
diff --git a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml
index e1f8414c..3dfd1569 100644
--- a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml
+++ b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml
@@ -337,6 +337,66 @@ paths:
description: Cleared
'409':
description: removal failed
+ /studies/{studyId}/calendar.ics:
+ get:
+ tags:
+ - calendar
+ description: Get study calendar for study as iCal
+ operationId: getStudyCalendar
+ parameters:
+ - $ref: '#/components/parameters/StudyId'
+ responses:
+ '200':
+ description: Successfully returned study calendar
+ content:
+ text/calendar:
+ schema:
+ type: string
+ '404':
+ description: Not found
+
+ /studies/{studyId}/timeline:
+ get:
+ tags:
+ - calendar
+ description: Get study timeline for study
+ operationId: getStudyTimeline
+ parameters:
+ - $ref: '#/components/parameters/StudyId'
+ - name: participant
+ in: query
+ schema:
+ $ref: '#/components/schemas/Id'
+ - name: studyGroup
+ in: query
+ schema:
+ $ref: '#/components/schemas/Id'
+ - name: referenceDate
+ in: query
+ description: reference date used to calculate relative schedules
+ schema:
+ type: string
+ format: date-time
+ - name: from
+ in: query
+ schema:
+ type: string
+ format: date
+ - name: to
+ in: query
+ schema:
+ type: string
+ format: date
+ responses:
+ '200':
+ description: Successfully returned study timeline
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StudyTimeline'
+ '404':
+ description: Not found
+
/studies/{studyId}/studyGroups:
post:
@@ -678,7 +738,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/TokenLabel'
+ $ref: '#/components/schemas/EndpointToken'
responses:
'201':
description: Token successfully added
@@ -729,6 +789,33 @@ paths:
'404':
description: not found
+ put:
+ tags:
+ - observations
+ description: Update label from observation endpoint token
+ operationId: updateTokenLabel
+ parameters:
+ - $ref: '#/components/parameters/StudyId'
+ - $ref: '#/components/parameters/ObservationId'
+ - $ref: '#/components/parameters/TokenId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EndpointToken'
+ responses:
+ '200':
+ description: Token label successfully updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EndpointToken'
+
+ '400':
+ description: could not update observation
+ '404':
+ description: not found
delete:
tags:
@@ -1277,6 +1364,8 @@ components:
type: string
consentInfo:
type: string
+ duration:
+ $ref: '#/components/schemas/StudyDuration'
finishText:
type: string
status:
@@ -1337,6 +1426,8 @@ components:
type: string
enum:
- draft
+ - preview
+ - paused-preview
- active
- paused
- closed
@@ -1354,6 +1445,8 @@ components:
type: string
purpose:
type: string
+ duration:
+ $ref: '#/components/schemas/StudyDuration'
numberOfParticipants:
type: integer
readOnly: true
@@ -1365,7 +1458,18 @@ components:
type: string
format: date-time
readOnly: true
-
+ StudyDuration:
+ type: object
+ properties:
+ value:
+ type: integer
+ unit:
+ type: string
+ description: unit of time
+ enum:
+ - MINUTE
+ - HOUR
+ - DAY
Participant:
type: object
properties:
@@ -1382,6 +1486,10 @@ components:
readOnly: true
status:
$ref: '#/components/schemas/ParticipantStatus'
+ start:
+ type: string
+ format: date-time
+ readOnly: true
created:
type: string
format: date-time
@@ -1422,7 +1530,16 @@ components:
type: object
additionalProperties: true
schedule:
- $ref: './Event.yaml/#/components/schemas/Event'
+ oneOf:
+ - $ref: './Event.yaml/#/components/schemas/Event'
+ - $ref: './RelativeEvent.yaml/#/components/schemas/RelativeEvent'
+ discriminator:
+ propertyName: type
+ # mapping:
+ # event:
+ # $ref: './Event.yaml/#/components/schemas/Event'
+ # relativeEvent:
+ # $ref: './RelativeEvent.yaml/#/components/schemas/RelativeEvent'
created:
type: string
format: date-time
@@ -1437,28 +1554,92 @@ components:
type: boolean
default: false
+
+ StudyTimeline:
+ type: object
+ properties:
+ participantSignup:
+ type: string
+ format: date-time
+ studyDuration:
+ type: object
+ properties:
+ from:
+ type: string
+ format: date
+ to:
+ type: string
+ format: date
+ observations:
+ type: array
+ items:
+ $ref: '#/components/schemas/ObservationTimelineEvent'
+ interventions:
+ type: array
+ items:
+ $ref: '#/components/schemas/InterventionTimelineEvent'
+
+ ObservationTimelineEvent:
+ type: object
+ properties:
+ observationId:
+ $ref: '#/components/schemas/Id'
+ studyGroupId:
+ $ref: '#/components/schemas/Id'
+ title:
+ type: string
+ purpose:
+ type: string
+ type:
+ type: string
+ start:
+ type: string
+ format: date-time
+ end:
+ type: string
+ format: date-time
+ hidden:
+ type: boolean
+ scheduleType:
+ type: string
+
+ InterventionTimelineEvent:
+ type: object
+ properties:
+ interventionId:
+ $ref: '#/components/schemas/Id'
+ studyGroupId:
+ $ref: '#/components/schemas/Id'
+ title:
+ type: string
+ purpose:
+ type: string
+ start:
+ type: string
+ format: date-time
+ scheduleType:
+ type: string
+
EndpointToken:
type: object
properties:
tokenId:
$ref: '#/components/schemas/Id'
tokenLabel:
- $ref: '#/components/schemas/TokenLabel'
+ type: string
created:
type: string
format: date-time
readOnly: true
token:
type: string
+ readOnly: true
required:
- tokenId
- tokenLabel
- created
- token
- TokenLabel:
- type: string
-
ParticipationData:
type: object
properties:
@@ -1514,7 +1695,11 @@ components:
purpose:
type: string
schedule:
- $ref: './Event.yaml/#/components/schemas/Event'
+ oneOf:
+ - $ref: './Event.yaml/#/components/schemas/Event'
+ - $ref: './RelativeEvent.yaml/#/components/schemas/RelativeEvent'
+ discriminator:
+ propertyName: type
trigger:
$ref: '#/components/schemas/Trigger'
actions:
@@ -1598,6 +1783,28 @@ components:
type: array
items:
$ref: '#/components/schemas/Intervention'
+ participants:
+ type: array
+ items:
+ $ref: '#/components/schemas/ParticipantInfo'
+ integrations:
+ type: array
+ items:
+ $ref: '#/components/schemas/IntegrationInfo'
+
+ ParticipantInfo:
+ type: object
+ properties:
+ studyGroup:
+ type: integer
+
+ IntegrationInfo:
+ type: object
+ properties:
+ name:
+ type: string
+ observationId:
+ $ref: '#/components/schemas/Id'
UserSearchResultList:
type: object
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/CalendarControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/CalendarControllerTest.java
new file mode 100644
index 00000000..0908610c
--- /dev/null
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/CalendarControllerTest.java
@@ -0,0 +1,140 @@
+package io.redlink.more.studymanager.controller.studymanager;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.redlink.more.studymanager.model.AuthenticatedUser;
+import io.redlink.more.studymanager.model.Observation;
+import io.redlink.more.studymanager.model.PlatformRole;
+import io.redlink.more.studymanager.model.scheduler.Event;
+import io.redlink.more.studymanager.model.scheduler.RelativeEvent;
+import io.redlink.more.studymanager.model.timeline.ObservationTimelineEvent;
+import io.redlink.more.studymanager.model.timeline.StudyTimeline;
+import io.redlink.more.studymanager.service.CalendarService;
+import io.redlink.more.studymanager.service.OAuth2AuthenticationService;
+import org.apache.commons.lang3.Range;
+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.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+
+@WebMvcTest(CalendarApiV1Controller.class)
+@AutoConfigureMockMvc(addFilters = false)
+public class CalendarControllerTest {
+ @MockBean
+ CalendarService service;
+
+ @MockBean
+ OAuth2AuthenticationService authService;
+
+ @Autowired
+ ObjectMapper mapper;
+
+ @Autowired
+ MockMvc mvc;
+
+ @BeforeEach
+ void setup() {
+ when(authService.getCurrentUser()).thenReturn(
+ new AuthenticatedUser(
+ UUID.randomUUID().toString(),
+ "Testname", "Test@mail", "Test Institution",
+ EnumSet.allOf(PlatformRole.class)
+ )
+ );
+ }
+
+ @Test
+ @DisplayName("getStudyTimeline should return the timeline of a study's observations")
+ void testGetStudyTimeline() throws Exception {
+ Integer studyGroup1 = null;
+ Integer studyGroup2 = 1;
+ Instant referenceDate = Instant.now();
+ LocalDate from = LocalDate.of(2024, 2, 1);
+ LocalDate to = LocalDate.of(2024, 5, 1);
+
+ when(service.getTimeline(any(), any(), any(), any(OffsetDateTime.class), any(LocalDate.class), any(LocalDate.class)))
+ .thenAnswer(invocationOnMock -> {
+ return new StudyTimeline(
+ referenceDate,
+ Range.between(from, to, LocalDate::compareTo),
+ List.of(
+ ObservationTimelineEvent.fromObservation(
+ new Observation()
+ .setObservationId(1)
+ .setStudyId(invocationOnMock.getArgument(0))
+ .setStudyGroupId(studyGroup1)
+ .setTitle("title 1")
+ .setPurpose("purpose 1")
+ .setType("type 1")
+ .setHidden(Boolean.FALSE)
+ .setSchedule(new Event()),
+ ((LocalDate)invocationOnMock.getArgument(4)).atStartOfDay(ZoneId.systemDefault()).toInstant(),
+ ((LocalDate)invocationOnMock.getArgument(5)).atStartOfDay(ZoneId.systemDefault()).toInstant()
+ ),
+ ObservationTimelineEvent.fromObservation(
+ new Observation()
+ .setObservationId(2)
+ .setStudyId(invocationOnMock.getArgument(0))
+ .setStudyGroupId(studyGroup2)
+ .setTitle("title 2")
+ .setPurpose("purpose 2")
+ .setType("type 2")
+ .setHidden(Boolean.TRUE)
+ .setSchedule(new RelativeEvent()),
+ ((LocalDate)invocationOnMock.getArgument(4)).atStartOfDay(ZoneId.systemDefault()).toInstant(),
+ ((LocalDate)invocationOnMock.getArgument(5)).atStartOfDay(ZoneId.systemDefault()).toInstant()
+ )
+ ),
+ List.of()
+ );
+ });
+
+ DateTimeFormatter pattern = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX").withZone(ZoneId.systemDefault());
+
+ mvc.perform(get("/api/v1/studies/3/timeline")
+ .param("participant", String.valueOf(2))
+ .param("referenceDate", referenceDate.toString())
+ .param("from", from.toString())
+ .param("to", to.toString())
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(jsonPath("$.observations.length()").value(2))
+ .andExpect(jsonPath("$.observations[0].observationId").value(1))
+ .andExpect(jsonPath("$.observations[0].studyGroupId").value(studyGroup1))
+ .andExpect(jsonPath("$.observations[0].title").value("title 1"))
+ .andExpect(jsonPath("$.observations[0].purpose").value("purpose 1"))
+ .andExpect(jsonPath("$.observations[0].type").value("type 1"))
+ .andExpect(jsonPath("$.observations[0].start").exists())
+ .andExpect(jsonPath("$.observations[0].end").exists())
+ .andExpect(jsonPath("$.observations[0].hidden").value(Boolean.FALSE))
+ .andExpect(jsonPath("$.observations[0].scheduleType").value(Event.TYPE))
+
+ .andExpect(jsonPath("$.observations[1].observationId").value(2))
+ .andExpect(jsonPath("$.observations[1].studyGroupId").value(studyGroup2))
+ .andExpect(jsonPath("$.observations[1].title").value("title 2"))
+ .andExpect(jsonPath("$.observations[1].purpose").value("purpose 2"))
+ .andExpect(jsonPath("$.observations[1].type").value("type 2"))
+ .andExpect(jsonPath("$.observations[1].start").exists())
+ .andExpect(jsonPath("$.observations[1].end").exists())
+ .andExpect(jsonPath("$.observations[1].hidden").value(Boolean.TRUE))
+ .andExpect(jsonPath("$.observations[1].scheduleType").value(RelativeEvent.TYPE));
+
+ }
+}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ImportExportControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ImportExportControllerTest.java
index 6dcf265a..b7489692 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ImportExportControllerTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ImportExportControllerTest.java
@@ -13,6 +13,8 @@
import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.scheduler.Event;
+import io.redlink.more.studymanager.model.scheduler.RecurrenceRule;
import io.redlink.more.studymanager.repository.DownloadTokenRepository;
import io.redlink.more.studymanager.service.ImportExportService;
import io.redlink.more.studymanager.service.OAuth2AuthenticationService;
@@ -30,6 +32,7 @@
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -131,7 +134,9 @@ void testImportExportStudy() throws Exception {
.setObservations(List.of(observation))
.setInterventions(List.of(intervention))
.setTriggers(Map.of(intervention.getInterventionId(), trigger))
- .setActions(Map.of(intervention.getInterventionId(), List.of(action)));
+ .setActions(Map.of(intervention.getInterventionId(), List.of(action)))
+ .setParticipants(new ArrayList<>())
+ .setIntegrations(new ArrayList<>());
when(importExportService.exportStudy(anyLong(), any()))
.thenAnswer(invocationOnMock -> studyImportExport);
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java
index f1f6ebee..0be3b95c 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java
@@ -16,7 +16,7 @@
import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.model.Action;
import io.redlink.more.studymanager.model.AuthenticatedUser;
-import io.redlink.more.studymanager.model.Event;
+import io.redlink.more.studymanager.model.scheduler.Event;
import io.redlink.more.studymanager.model.Intervention;
import io.redlink.more.studymanager.model.PlatformRole;
import io.redlink.more.studymanager.model.Trigger;
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java
index 02daa6de..a1b91d5d 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java
@@ -9,13 +9,16 @@
package io.redlink.more.studymanager.controller.studymanager;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.redlink.more.studymanager.api.v1.model.EventDTO;
+import io.redlink.more.studymanager.api.v1.model.EndpointTokenDTO;
import io.redlink.more.studymanager.api.v1.model.ObservationDTO;
+import io.redlink.more.studymanager.api.v1.model.ObservationScheduleDTO;
import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.scheduler.Event;
import io.redlink.more.studymanager.service.IntegrationService;
import io.redlink.more.studymanager.service.OAuth2AuthenticationService;
import io.redlink.more.studymanager.service.ObservationService;
import io.redlink.more.studymanager.utils.MapperUtils;
+import java.time.OffsetDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -144,7 +147,7 @@ void testEmptySchedule() throws Exception {
ObservationDTO observationRequest = new ObservationDTO()
.studyId(1L)
.title("a different title")
- .schedule(MapperUtils.readValue(new HashMap(), EventDTO.class))
+ .schedule(MapperUtils.readValue("{\"type\":\"Event\"}", ObservationScheduleDTO.class))
.observationId(1);
mvc.perform(post("/api/v1/studies/1/observations")
@@ -154,7 +157,7 @@ void testEmptySchedule() throws Exception {
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("title"))
.andExpect(jsonPath("$.studyId").value(observationRequest.getStudyId()))
- .andExpect(jsonPath("$.schedule").value(MapperUtils.readValue(new HashMap(), EventDTO.class)))
+ .andExpect(jsonPath("$.schedule").value(MapperUtils.readValue("{\"type\":\"Event\"}", ObservationScheduleDTO.class)))
.andExpect(jsonPath("$.modified").exists())
.andExpect(jsonPath("$.created").exists());
}
@@ -162,26 +165,26 @@ void testEmptySchedule() throws Exception {
@Test
@DisplayName("Add token should create and return token with id, label, timestamp and secret set, only if label is valid")
void testAddToken() throws Exception{
- EndpointToken token = new EndpointToken(
+ EndpointTokenDTO token = new EndpointTokenDTO(
1,
"testLabel",
- Instant.now(),
+ OffsetDateTime.now(),
"test");
when(integrationService.addToken(anyLong(), anyInt(), anyString()))
.thenAnswer(invocationOnMock -> Optional.of(new EndpointToken(
- token.tokenId(),
+ token.getTokenId(),
invocationOnMock.getArgument(2),
- token.created(),
- token.token()
+ Instant.now(),
+ token.getToken()
)));
mvc.perform(post("/api/v1/studies/1/observations/1/tokens")
- .content(token.tokenLabel())
+ .content(mapper.writeValueAsString(token))
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isCreated())
- .andExpect(jsonPath("$.tokenId").value(token.tokenId()))
- .andExpect(jsonPath("$.tokenLabel").value(token.tokenLabel()))
- .andExpect(jsonPath("$.token").value(token.token()))
+ .andExpect(jsonPath("$.tokenId").value(token.getTokenId()))
+ .andExpect(jsonPath("$.tokenLabel").value(token.getTokenLabel()))
+ .andExpect(jsonPath("$.token").value(token.getToken()))
.andExpect(jsonPath("$.created").exists());
mvc.perform(post("/api/v1/studies/1/observations/1/tokens")
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/repository/IntegrationRepositoryTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/repository/IntegrationRepositoryTest.java
index bcd1b93c..652a7738 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/repository/IntegrationRepositoryTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/repository/IntegrationRepositoryTest.java
@@ -10,6 +10,8 @@
import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.scheduler.Event;
+import io.redlink.more.studymanager.model.scheduler.RecurrenceRule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java
index 6207f9fe..2e18ae2c 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java
@@ -11,6 +11,8 @@
import io.redlink.more.studymanager.core.properties.ActionProperties;
import io.redlink.more.studymanager.model.*;
import io.redlink.more.studymanager.core.properties.TriggerProperties;
+import io.redlink.more.studymanager.model.scheduler.Event;
+import io.redlink.more.studymanager.model.scheduler.RecurrenceRule;
import io.redlink.more.studymanager.utils.MapperUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -72,7 +74,7 @@ void testInsertListUpdateDelete() {
assertThat(interventionResponse.getInterventionId()).isNotNull();
assertThat(interventionResponse.getTitle()).isEqualTo(intervention.getTitle());
- assertThat(interventionResponse.getSchedule().getDateStart()).isEqualTo(startTime);
+ assertThat(((Event)interventionResponse.getSchedule()).getDateStart()).isEqualTo(startTime);
assertThat(MapperUtils.writeValueAsString(interventionResponse.getSchedule()))
.isEqualTo(MapperUtils.writeValueAsString(intervention.getSchedule()));
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/repository/NameValuePairRepositoryTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/repository/NameValuePairRepositoryTest.java
index ecf5a6c3..b65e8bd9 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/repository/NameValuePairRepositoryTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/repository/NameValuePairRepositoryTest.java
@@ -8,6 +8,11 @@
*/
package io.redlink.more.studymanager.repository;
+import io.redlink.more.studymanager.model.Contact;
+import io.redlink.more.studymanager.model.Observation;
+import io.redlink.more.studymanager.model.Study;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -18,39 +23,52 @@
import java.io.Serializable;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Testcontainers
@ActiveProfiles("test-containers-flyway")
public class NameValuePairRepositoryTest {
+ @Autowired
+ private StudyRepository studyRepository;
+
+ @Autowired
+ private ObservationRepository observationRepository;
+
@Autowired
private NameValuePairRepository nvpairs;
@BeforeEach
- void deleteAll() {
- nvpairs.clear();
+ void before() {
+ studyRepository.clear();
+ //Test cascade deletion
+ assertTrue(nvpairs.noObservationValues());
}
@Test
- public void testCrud() {
- nvpairs.setValue("i1", "n1", "v1");
- assertThat(nvpairs.getValue("i1", "n1", String.class).get()).isEqualTo("v1");
- assertFalse(nvpairs.getValue("i1", "n2", String.class).isPresent());
- assertFalse(nvpairs.getValue("i2", "n1", String.class).isPresent());
+ void testCrud() {
+ long sid = studyRepository.insert(new Study().setContact(new Contact())).getStudyId();
+ int oid1 = observationRepository.insert(new Observation().setStudyId(sid).setType("t").setHidden(false)).getObservationId();
+ int oid2 = observationRepository.insert(new Observation().setStudyId(sid).setType("t").setHidden(false)).getObservationId();
+
+ nvpairs.setObservationValue(sid, oid1, "n1", "v1");
+ assertThat(nvpairs.getObservationValue(sid, oid1, "n1", String.class).get()).isEqualTo("v1");
+ assertFalse(nvpairs.getObservationValue(sid, oid1, "n2", String.class).isPresent());
+ assertFalse(nvpairs.getObservationValue(sid, oid2, "n1", String.class).isPresent());
assertThrows(ClassCastException.class, () -> {
- nvpairs.getValue("i1", "n1", Integer.class);
+ nvpairs.getObservationValue(sid, oid1, "n1", Integer.class);
});
- nvpairs.removeValue("i1", "n1");
- assertFalse(nvpairs.getValue("i1", "n1", String.class).isPresent());
+ nvpairs.removeObservationValue(sid, oid1, "n1");
+ assertFalse(nvpairs.getObservationValue(sid, oid1, "n1", String.class).isPresent());
}
@Test
public void testMoreComplexObject() {
- nvpairs.setValue("i2", "complex", new SampleObject("v1"));
- assertThat(nvpairs.getValue("i2", "complex", SampleObject.class).get().getValue()).isEqualTo("v1");
+ long sid1 = studyRepository.insert(new Study().setContact(new Contact())).getStudyId();
+ int oid1 = observationRepository.insert(new Observation().setStudyId(sid1).setType("t").setHidden(false)).getObservationId();
+ nvpairs.setObservationValue(sid1, oid1, "complex", new SampleObject("v1"));
+ assertThat(nvpairs.getObservationValue(sid1, 1, "complex", SampleObject.class).get().getValue()).isEqualTo("v1");
}
}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java
index 2bace6f4..ca2e8ed7 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java
@@ -10,7 +10,9 @@
import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.scheduler.*;
import io.redlink.more.studymanager.utils.MapperUtils;
+import java.time.LocalTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -97,11 +99,38 @@ public void testInsertListUpdateDelete() {
.setNoSchedule(true)
);
- assertThat((observationRepository.listObservations(studyId)).size()).isEqualTo(2);
+ assertThat((observationRepository.listObservations(studyId)))
+ .as("List all Observations")
+ .hasSize(2);
+ assertThat((observationRepository.listObservationsForGroup(studyId, studyGroupId)))
+ .as("Include group-specific observations and globals")
+ .hasSize(2);
+ assertThat((observationRepository.listObservationsForGroup(studyId, -1)))
+ .as("Non-existing Group should only retrieve 'global' observations")
+ .hasSize(1);
+ assertThat((observationRepository.listObservationsForGroup(studyId, null)))
+ .as("-Group should only retrieve 'global' observations")
+ .hasSize(1)
+ .as("Check for the global observation")
+ .extracting(Observation::getObservationId)
+ .contains(observationResponse2.getObservationId());
+ // Delete the group specific observations
observationRepository.deleteObservation(studyId, observationResponse.getObservationId());
- assertThat((observationRepository.listObservations(studyId)).size()).isEqualTo(1);
+ assertThat((observationRepository.listObservations(studyId)))
+ .hasSize(1);
+ assertThat((observationRepository.listObservationsForGroup(studyId, studyGroupId)))
+ .hasSize(1);
observationRepository.deleteObservation(studyId, observationResponse2.getObservationId());
- assertThat((observationRepository.listObservations(studyId)).size()).isEqualTo(0);
+ assertThat((observationRepository.listObservations(studyId)))
+ .hasSize(0);
+
+ observation.setSchedule(new RelativeEvent()
+ .setDtstart(new RelativeDate().setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)).setTime(LocalTime.parse("12:00:00")))
+ .setDtend(new RelativeDate().setOffset(new Duration().setValue(2).setUnit(Duration.Unit.DAY)).setTime(LocalTime.parse("13:00:00"))));
+
+ Observation observationResponse3 = observationRepository.insert(observation);
+ assertThat(observationResponse3.getSchedule())
+ .isInstanceOf(RelativeEvent.class);
}
@Test
@@ -115,16 +144,31 @@ public void testParticipantObservationProperties() {
ObservationProperties op1 = new ObservationProperties(Map.of("hello", "world"));
ObservationProperties op2 = new ObservationProperties(Map.of("hello", "world2"));
- assertThat(observationRepository.getParticipantProperties(s1,p1,o1)).isEmpty();
+ assertThat(observationRepository.getParticipantProperties(s1,p1,o1))
+ .isEmpty();
observationRepository.setParticipantProperties(s1, p1, o1, op1);
- assertThat(observationRepository.getParticipantProperties(s1,p1,o1).get().getString("hello")).isEqualTo("world");
+ assertThat(observationRepository.getParticipantProperties(s1,p1,o1))
+ .get()
+ .extracting(op -> op.getString("hello"))
+ .isEqualTo("world");
observationRepository.setParticipantProperties(s1, p1, o1, op2);
- assertThat(observationRepository.getParticipantProperties(s1,p1,o1).get().getString("hello")).isEqualTo("world2");
+ assertThat(observationRepository.getParticipantProperties(s1,p1,o1))
+ .get()
+ .extracting(op -> op.getString("hello"))
+ .isEqualTo("world2");
observationRepository.setParticipantProperties(s1, p1, o2, op1);
- assertThat(observationRepository.getParticipantProperties(s1,p1,o1).get().getString("hello")).isEqualTo("world2");
- assertThat(observationRepository.getParticipantProperties(s1,p1,o2).get().getString("hello")).isEqualTo("world");
+ assertThat(observationRepository.getParticipantProperties(s1,p1,o1))
+ .get()
+ .extracting(op -> op.getString("hello"))
+ .isEqualTo("world2");
+ assertThat(observationRepository.getParticipantProperties(s1,p1,o2))
+ .get()
+ .extracting(op -> op.getString("hello"))
+ .isEqualTo("world");
observationRepository.removeParticipantProperties(s1, p1, o2);
- assertThat(observationRepository.getParticipantProperties(s1,p1,o2)).isEmpty();
- assertThat(observationRepository.getParticipantProperties(s1,p1,o1)).isPresent();
+ assertThat(observationRepository.getParticipantProperties(s1,p1,o2))
+ .isEmpty();
+ assertThat(observationRepository.getParticipantProperties(s1,p1,o1))
+ .isPresent();
}
}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/repository/StudyRepositoryTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/repository/StudyRepositoryTest.java
index 6e9303f0..b62e6f2b 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/repository/StudyRepositoryTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/repository/StudyRepositoryTest.java
@@ -10,6 +10,7 @@
import io.redlink.more.studymanager.model.Contact;
import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.scheduler.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -64,6 +65,7 @@ void testInsert() {
void testUpdate() {
Study insert = new Study()
.setTitle("some title")
+ .setDuration(new Duration().setValue(1).setUnit(Duration.Unit.DAY))
.setContact(new Contact().setPerson("test").setEmail("test"));
Study inserted = studyRepository.insert(insert);
@@ -71,13 +73,13 @@ void testUpdate() {
Study update = new Study()
.setStudyId(inserted.getStudyId())
.setTitle("some new title")
+ .setDuration(new Duration().setValue(2).setUnit(Duration.Unit.HOUR))
.setContact(new Contact().setPerson("test2").setEmail("test2"));
Optional optUpdated = studyRepository.update(update, null);
assertThat(optUpdated).isPresent();
Study updated = optUpdated.get();
-
Optional optQueried = studyRepository.getById(inserted.getStudyId());
assertThat(optQueried).isPresent();
Study queried = optQueried.get();
@@ -89,11 +91,20 @@ void testUpdate() {
assertThat(queried.getContact().getEmail()).isEqualTo(updated.getContact().getEmail());
assertThat(update.getTitle()).isEqualTo(updated.getTitle());
+ assertThat(update.getDuration().getValue()).isEqualTo(updated.getDuration().getValue());
+ assertThat(update.getDuration().getUnit()).isEqualTo(updated.getDuration().getUnit());
assertThat(inserted.getStudyId()).isEqualTo(updated.getStudyId());
assertThat(inserted.getCreated()).isEqualTo(updated.getCreated());
- assertThat(inserted.getModified().toEpochMilli()).isLessThan(updated.getModified().toEpochMilli());
+ assertThat(inserted.getModified()).isBefore(updated.getModified());
assertThat(inserted.getContact().getPerson()).isNotEqualTo(updated.getContact().getPerson());
assertThat(inserted.getContact().getEmail()).isNotEqualTo(updated.getContact().getEmail());
+
+ Study insert_no_duration = new Study()
+ .setTitle("some title")
+ .setContact(new Contact().setPerson("test").setEmail("test"));
+
+ Study inserted_no_duration = studyRepository.insert(insert_no_duration);
+ assertThat(inserted_no_duration.getDuration()).isNull();
}
@Test
@@ -120,9 +131,24 @@ void testSetState() {
Study study = studyRepository.insert(new Study().setContact(new Contact().setPerson("test").setEmail("test")));
assertThat(study.getStudyState()).isEqualTo(Study.Status.DRAFT);
assertThat(study.getStartDate()).isNull();
+ assertThat(study.getEndDate()).isNull();
- studyRepository.setStateById(study.getStudyId(), Study.Status.ACTIVE);
+ study = assertPresent(studyRepository.setStateById(study.getStudyId(), Study.Status.PREVIEW));
+ assertThat(study.getStudyState()).isEqualTo(Study.Status.PREVIEW);
+ assertThat(study.getStartDate()).isNotNull();
+ assertThat(study.getEndDate()).isNull();
+ study = assertPresent(studyRepository.setStateById(study.getStudyId(), Study.Status.PAUSED_PREVIEW));
+ assertThat(study.getStudyState()).isEqualTo(Study.Status.PAUSED_PREVIEW);
+ assertThat(study.getStartDate()).isNotNull();
+ assertThat(study.getEndDate()).isNull();
+
+ study = assertPresent(studyRepository.setStateById(study.getStudyId(), Study.Status.DRAFT));
+ assertThat(study.getStudyState()).isEqualTo(Study.Status.DRAFT);
+ assertThat(study.getStartDate()).isNull();
+ assertThat(study.getEndDate()).isNull();
+
+ studyRepository.setStateById(study.getStudyId(), Study.Status.ACTIVE);
study = assertPresent(studyRepository.getById(study.getStudyId()));
assertThat(study.getStudyState()).isEqualTo(Study.Status.ACTIVE);
assertThat(study.getStartDate()).isNotNull();
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestJob.java b/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestJob.java
index 8286f26e..c2c90620 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestJob.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestJob.java
@@ -8,7 +8,7 @@
*/
package io.redlink.more.studymanager.scheduler;
-import io.redlink.more.studymanager.sdk.MoreSDK;
+import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
@@ -21,13 +21,13 @@ public class TestJob implements Job {
private static final Logger LOGGER = LoggerFactory.getLogger(TestJob.class);
@Autowired
- MoreSDK moreSDK;
+ MoreTriggerSDK moreSDK;
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
String issuer = jobExecutionContext.getJobDetail().getJobDataMap().getString("issuer");
- int count = moreSDK.getValue(issuer, "i", Integer.class).orElse(0);
+ int count = moreSDK.getValue(issuer, Integer.class).orElse(0);
LOGGER.debug("scheduled {}: count {}", issuer, count);
- moreSDK.setValue("i", "c", count+1);
+ moreSDK.setValue(issuer, count+1);
}
}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestQrtzScheduler.java b/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestQrtzScheduler.java
index 3128fca1..2d6fee1e 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestQrtzScheduler.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/scheduler/TestQrtzScheduler.java
@@ -8,6 +8,7 @@
*/
package io.redlink.more.studymanager.scheduler;
+import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK;
import io.redlink.more.studymanager.sdk.MoreSDK;
import org.junit.jupiter.api.Test;
import org.quartz.*;
@@ -38,7 +39,7 @@
public class TestQrtzScheduler {
@MockBean
- private MoreSDK moreSDK;
+ private MoreTriggerSDK moreSDK;
@Autowired
private SchedulerFactoryBean factory;
@@ -48,8 +49,8 @@ public void testScheduling() throws SchedulerException, InterruptedException {
Map store = new HashMap<>();
- when(moreSDK.getValue(anyString(), anyString(), any())).thenAnswer(in -> {
- String key = in.getArgument(0) + "-" + in.getArgument(1);
+ when(moreSDK.getValue(anyString(), any())).thenAnswer(in -> {
+ String key = in.getArgument(0);
return Optional.of(store.computeIfAbsent(key, s -> new AtomicInteger()).incrementAndGet());
});
@@ -72,14 +73,14 @@ public void testScheduling() throws SchedulerException, InterruptedException {
scheduler.unscheduleJob(t1.getKey());
scheduler.deleteJob(job1.getKey());
- assertThat(store.get("issuer1-i").get()).isGreaterThanOrEqualTo(7);
- assertThat(store.get("issuer2-i").get()).isGreaterThanOrEqualTo(4);
+ assertThat(store.get("issuer1").get()).isGreaterThanOrEqualTo(7);
+ assertThat(store.get("issuer2").get()).isGreaterThanOrEqualTo(4);
TimeUnit.MILLISECONDS.sleep(1200);
scheduler.unscheduleJob(t2.getKey());
scheduler.deleteJob(job2.getKey());
- assertThat(store.get("issuer2-i").get()).isEqualTo(8);
+ assertThat(store.get("issuer2").get()).isEqualTo(8);
}
private JobDetail jobDetail(String id) {
@@ -100,10 +101,10 @@ public void testTriggerStopWithStringKey() throws SchedulerException, Interrupte
TimeUnit.MILLISECONDS.sleep(500);
scheduler.unscheduleJob(new TriggerKey(triggerId));
scheduler.deleteJob(job.getKey());
- verify(moreSDK, atLeast(2)).getValue(any(),any(),any());
+ verify(moreSDK, atLeast(2)).getValue(any(),any());
reset(moreSDK);
TimeUnit.MILLISECONDS.sleep(200);
- verify(moreSDK, never()).getValue(any(),any(),any());
+ verify(moreSDK, never()).getValue(any(),any());
scheduler.shutdown();
}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/CalendarServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/CalendarServiceTest.java
new file mode 100644
index 00000000..49119379
--- /dev/null
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/CalendarServiceTest.java
@@ -0,0 +1,191 @@
+package io.redlink.more.studymanager.service;
+
+import io.redlink.more.studymanager.core.properties.TriggerProperties;
+import io.redlink.more.studymanager.exception.NotFoundException;
+import io.redlink.more.studymanager.model.Intervention;
+import io.redlink.more.studymanager.model.Observation;
+import io.redlink.more.studymanager.model.Participant;
+import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.Trigger;
+import io.redlink.more.studymanager.model.scheduler.Duration;
+import io.redlink.more.studymanager.model.scheduler.Event;
+import io.redlink.more.studymanager.model.scheduler.RecurrenceRule;
+import io.redlink.more.studymanager.model.scheduler.RelativeDate;
+import io.redlink.more.studymanager.model.scheduler.RelativeEvent;
+import io.redlink.more.studymanager.model.scheduler.RelativeRecurrenceRule;
+import io.redlink.more.studymanager.model.timeline.StudyTimeline;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class CalendarServiceTest {
+ @Mock
+ StudyService studyService;
+
+ @Mock
+ ObservationService observationService;
+
+ @Mock
+ InterventionService interventionService;
+
+ @Mock
+ ParticipantService participantService;
+
+ @InjectMocks
+ CalendarService calendarService;
+
+ @Test
+ void testStudyNotFound() {
+ when(studyService.getStudy(any(), any())).thenReturn(Optional.empty());
+ assertThrows(NotFoundException.class, () -> calendarService.getTimeline(1L, 1, 1, OffsetDateTime.now(), LocalDate.now(), LocalDate.now()));
+ }
+
+ @Test
+ void testParticipantNotFound() {
+ when(participantService.getParticipant(any(), any()))
+ .thenReturn(null);
+ when(studyService.getStudy(any(), any()))
+ .thenReturn(Optional.of(
+ new Study()
+ .setPlannedStartDate(LocalDate.now())
+ .setPlannedEndDate(LocalDate.now().plusDays(3))
+ ));
+ assertThrows(NotFoundException.class, () -> calendarService.getTimeline(1L, 1,1, OffsetDateTime.now(), LocalDate.now(), LocalDate.now()));
+ }
+
+ @Test
+ void testGetTimeline() {
+ Study study = new Study()
+ .setStudyId(1L)
+ .setPlannedStartDate(LocalDate.of(2024, 5, 9))
+ .setStartDate(LocalDate.of(2024, 5, 10))
+ .setPlannedEndDate(LocalDate.of(2024, 5, 14))
+ .setDuration(new Duration().setUnit(Duration.Unit.DAY).setValue(5));
+
+ Participant participant = new Participant().setStudyGroupId(2);
+
+ Observation observationAbsolute = new Observation()
+ .setObservationId(1)
+ .setTitle("title")
+ .setPurpose("purpose")
+ .setType("accelerometer")
+ .setSchedule(new Event()
+ .setDateStart(LocalDate.of(2024, 5, 10).atTime(16, 10).atZone(ZoneId.systemDefault()).toInstant())
+ .setDateEnd(LocalDate.of(2024, 5, 10).atTime(18, 10).atZone(ZoneId.systemDefault()).toInstant()))
+ .setHidden(true);
+
+ Observation observationAbsoluteRecurrent = new Observation()
+ .setObservationId(2)
+ .setTitle("title2")
+ .setPurpose("purpose2")
+ .setType("accelerometer2")
+ .setSchedule(new Event()
+ .setDateStart(LocalDate.of(2024, 5, 10).atTime(16, 10).atZone(ZoneId.systemDefault()).toInstant())
+ .setDateEnd(LocalDate.of(2024, 5, 10).atTime(18, 10).atZone(ZoneId.systemDefault()).toInstant())
+ .setRRule(new RecurrenceRule()
+ .setFreq("DAILY")
+ .setCount(2)))
+ .setHidden(false);
+
+ Observation observationRelative = new Observation()
+ .setObservationId(3)
+ .setTitle("title3")
+ .setPurpose("purpose3")
+ .setType("accelerometer3")
+ .setSchedule(new RelativeEvent()
+ .setDtstart(new RelativeDate()
+ .setTime(LocalTime.parse("20:00"))
+ .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.HOUR)))
+ .setDtend(new RelativeDate()
+ .setTime(LocalTime.parse("21:00"))
+ .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.HOUR))))
+ .setHidden(false);
+
+ Observation observationRelativeRecurrent = new Observation()
+ .setObservationId(4)
+ .setTitle("title4")
+ .setPurpose("purpose4")
+ .setType("accelerometer4")
+ .setSchedule(new RelativeEvent()
+ .setDtstart(new RelativeDate()
+ .setTime(LocalTime.parse("20:00"))
+ .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.HOUR)))
+ .setDtend(new RelativeDate()
+ .setTime(LocalTime.parse("21:00"))
+ .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.HOUR)))
+ .setRrrule(new RelativeRecurrenceRule()
+ .setFrequency(new Duration().setValue(1).setUnit(Duration.Unit.DAY))
+ .setEndAfter(new Duration().setValue(6).setUnit(Duration.Unit.DAY))))
+ .setHidden(false);
+
+ Intervention relativeIntervention = new Intervention()
+ .setInterventionId(1)
+ .setStudyGroupId(2)
+ .setTitle("title")
+ .setPurpose("purpose");
+
+ Intervention scheduledIntervention = new Intervention()
+ .setInterventionId(2)
+ .setStudyGroupId(2)
+ .setTitle("title2")
+ .setPurpose("purpose2");
+
+ TriggerProperties relativeProperties = new TriggerProperties();
+ relativeProperties.put("day", 1);
+ relativeProperties.put("hour", 1);
+
+ TriggerProperties cronProperties = new TriggerProperties();
+ cronProperties.put("cronSchedule", "0 0 12 * * ?");
+
+ Trigger relativeTrigger = new Trigger()
+ .setType("relative-time-trigger")
+ .setProperties(relativeProperties);
+
+ Trigger scheduledTrigger = new Trigger()
+ .setType("scheduled-trigger")
+ .setProperties(cronProperties);
+
+ when(studyService.getStudy(any(), any())).thenReturn(Optional.of(study));
+ when(participantService.getParticipant(any(), any())).thenReturn(participant);
+ when(studyService.getStudyDuration(any(), any()))
+ .thenReturn(Optional.of(new Duration().setValue(5).setUnit(Duration.Unit.DAY)));
+ when(observationService.listObservationsForGroup(any(), eq(participant.getStudyGroupId()))).thenReturn(
+ List.of(observationAbsolute, observationAbsoluteRecurrent, observationRelative, observationRelativeRecurrent));
+
+ when(interventionService.listInterventionsForGroup(any(), eq(participant.getStudyGroupId()))).thenReturn(
+ List.of(scheduledIntervention, relativeIntervention));
+ when(interventionService.getTriggerByIds(any(), eq(1))).thenReturn(relativeTrigger);
+ when(interventionService.getTriggerByIds(any(), eq(2))).thenReturn(scheduledTrigger);
+
+ StudyTimeline timeline = calendarService.getTimeline(
+ 1L,
+ 1,
+ 2,
+ OffsetDateTime.of(
+ LocalDate.of(2024, 5, 11),
+ LocalTime.of(10,10,10),
+ OffsetDateTime.now().getOffset()),
+ LocalDate.of(2024, 5, 9),
+ LocalDate.of(2024,5,17)
+ );
+
+ assertEquals(7, timeline.observationTimelineEvents().size());
+ assertEquals(6, timeline.interventionTimelineEvents().size());
+ }
+
+}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java
index cf61bd1f..a6db7cee 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java
@@ -12,6 +12,7 @@
import io.redlink.more.studymanager.core.properties.ObservationProperties;
import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.scheduler.Event;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -23,10 +24,8 @@
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.time.LocalDate;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Map;
-import java.util.UUID;
+import java.util.*;
+import java.util.function.Predicate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@@ -37,6 +36,9 @@ public class ImportExportServiceTest {
@Spy
private ParticipantService participantService = mock(ParticipantService.class);
+ @Spy
+ private IntegrationService integrationService = mock(IntegrationService.class);
+
@Mock
private StudyService studyService;
@@ -68,10 +70,16 @@ public class ImportExportServiceTest {
private ArgumentCaptor triggerCaptor;
@Captor
- private ArgumentCaptor actionCaptor;
+ private ArgumentCaptor> actionCaptor;
+
+ @Captor
+ private ArgumentCaptor idLongCaptor;
@Captor
- private ArgumentCaptor idCaptor;
+ private ArgumentCaptor idIntegerCaptor;
+
+ @Captor
+ private ArgumentCaptor aliasCaptor;
private final AuthenticatedUser currentUser = new AuthenticatedUser(
UUID.randomUUID().toString(),
@@ -93,6 +101,7 @@ void testImportParticipants() throws FileNotFoundException {
@Test
@DisplayName("Study configuration should be imported and set id's correctly")
void testImportStudy() {
+ Long studyId = 1L;
StudyImportExport studyImport = new StudyImportExport()
.setStudy(new Study()
.setTitle("title")
@@ -103,6 +112,7 @@ void testImportStudy() {
.setPlannedEndDate(LocalDate.now()))
.setObservations(List.of(
new Observation()
+ .setObservationId(1)
.setTitle("observation Title")
.setPurpose("observation purpose")
.setParticipantInfo("observation info")
@@ -111,6 +121,7 @@ void testImportStudy() {
.setProperties(new ObservationProperties())
.setSchedule(new Event()),
new Observation()
+ .setObservationId(3)
.setTitle("observation Title")
.setPurpose("observation purpose")
.setParticipantInfo("observation info")
@@ -145,36 +156,56 @@ void testImportStudy() {
.setProperties(new TriggerProperties())))
.setActions(Map.of(2, List.of(new Action()
.setType("sth")
- .setProperties(new ActionProperties()))));
+ .setProperties(new ActionProperties()))))
+ .setParticipants(List.of(
+ new StudyImportExport.ParticipantInfo(0),
+ new StudyImportExport.ParticipantInfo(0),
+ new StudyImportExport.ParticipantInfo(0),
+ new StudyImportExport.ParticipantInfo(2),
+ new StudyImportExport.ParticipantInfo(2),
+ new StudyImportExport.ParticipantInfo(2),
+ new StudyImportExport.ParticipantInfo(4),
+ new StudyImportExport.ParticipantInfo(4)
+ ))
+ .setIntegrations(List.of(
+ new IntegrationInfo("Integration 1", 1),
+ new IntegrationInfo("Integration 2", 3)
+ ));
when(studyService.createStudy(any(), any()))
.thenAnswer(invocationOnMock ->
- ((Study) invocationOnMock.getArgument(0)).setStudyId(1L));
- when(studyGroupService.createStudyGroup(any()))
+ ((Study) invocationOnMock.getArgument(0)).setStudyId(studyId));
+ when(observationService.importObservation(any(), any()))
.thenAnswer(invocationOnMock ->
- ((StudyGroup) invocationOnMock.getArgument(0)).setStudyGroupId(
- ((StudyGroup) invocationOnMock.getArgument(0)).getStudyGroupId()-1));
- when(interventionService.addIntervention(interventionCaptor.capture())).thenAnswer(
- invocationOnMock ->
- ((Intervention) invocationOnMock.getArgument(0)).setInterventionId(
- ((Intervention) invocationOnMock.getArgument(0)).getInterventionId()-1));
+ ((Observation) invocationOnMock.getArgument(1)).setStudyId(studyId));
+ when(interventionService.importIntervention(any(), any(), any(), any()))
+ .thenAnswer(invocationOnMock ->
+ ((Intervention) invocationOnMock.getArgument(1)).setStudyId(studyId));
importExportService.importStudy(studyImport, currentUser);
- verify(observationService, times(2)).addObservation(observationCaptor.capture());
- verify(interventionService, times(1)).updateTrigger(any(), idCaptor.capture(), triggerCaptor.capture());
- verify(interventionService, times(1)).createAction(any(), idCaptor.capture(), actionCaptor.capture());
+ ArgumentCaptor studyGroupCaptor = ArgumentCaptor.forClass(StudyGroup.class);
+ verify(studyGroupService, times(2)).importStudyGroup(idLongCaptor.capture(), studyGroupCaptor.capture());
+ assertThat(studyGroupCaptor.getAllValues()).hasSize(2);
+ assertThat(studyGroupCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(2);
+ assertThat(studyGroupCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(3);
+
+ verify(observationService, times(2)).importObservation(idLongCaptor.capture(), observationCaptor.capture());
+ verify(interventionService, times(2)).importIntervention(idLongCaptor.capture(), interventionCaptor.capture(), triggerCaptor.capture(), actionCaptor.capture());
+ verify(participantService, times(8)).createParticipant(participantsCaptor.capture());
+ verify(integrationService, times(2)).addToken(idLongCaptor.capture(), idIntegerCaptor.capture(), aliasCaptor.capture());
- assertThat(observationCaptor.getAllValues().get(0).getStudyId()).isEqualTo(1L);
- assertThat(observationCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(2);
+ assertThat(observationCaptor.getAllValues().get(0).getObservationId()).isEqualTo(1);
+ assertThat(observationCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(3);
+ assertThat(observationCaptor.getAllValues().get(1).getObservationId()).isEqualTo(3);
assertThat(observationCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(null);
- assertThat(interventionCaptor.getAllValues().get(0).getStudyId()).isEqualTo(1L);
- assertThat(interventionCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(1);
- assertThat(interventionCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(2);
+ assertThat(interventionCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(2);
+ assertThat(interventionCaptor.getAllValues().get(0).getInterventionId()).isEqualTo(2);
+ assertThat(interventionCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(3);
+ assertThat(interventionCaptor.getAllValues().get(1).getInterventionId()).isEqualTo(3);
- assertThat(idCaptor.getAllValues().get(0)).isEqualTo(2);
- assertThat(idCaptor.getAllValues().get(1)).isEqualTo(1);
+ assertThat(idLongCaptor.getAllValues()).allMatch(Predicate.isEqual(1L));
}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/ParticipantServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/ParticipantServiceTest.java
index 18343c78..e969167b 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/service/ParticipantServiceTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/ParticipantServiceTest.java
@@ -8,7 +8,6 @@
*/
package io.redlink.more.studymanager.service;
-import co.elastic.clients.elasticsearch.ElasticsearchClient;
import io.redlink.more.studymanager.model.Participant;
import io.redlink.more.studymanager.model.generator.RandomTokenGenerator;
import io.redlink.more.studymanager.repository.ParticipantRepository;
@@ -17,15 +16,13 @@
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
-import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
-import java.util.Optional;
-
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ParticipantServiceTest {
@@ -36,8 +33,8 @@ class ParticipantServiceTest {
@Mock
StudyStateService studyStateService;
- @Spy
- ElasticService elasticService = new ElasticService(mock(ElasticsearchClient.class));
+ @Mock
+ ElasticService elasticService;
@InjectMocks
ParticipantService participantService;
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java
index 2a879b52..b3c0b2f2 100644
--- a/studymanager/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java
@@ -9,25 +9,37 @@
package io.redlink.more.studymanager.service;
import io.redlink.more.studymanager.exception.BadRequestException;
-import io.redlink.more.studymanager.model.*;
+import io.redlink.more.studymanager.model.AuthenticatedUser;
+import io.redlink.more.studymanager.model.Contact;
+import io.redlink.more.studymanager.model.MoreUser;
+import io.redlink.more.studymanager.model.Participant;
+import io.redlink.more.studymanager.model.PlatformRole;
+import io.redlink.more.studymanager.model.Study;
+import io.redlink.more.studymanager.model.StudyRole;
+import io.redlink.more.studymanager.model.User;
import io.redlink.more.studymanager.repository.StudyAclRepository;
import io.redlink.more.studymanager.repository.StudyRepository;
import io.redlink.more.studymanager.repository.UserRepository;
import java.time.Instant;
-import java.util.*;
-
+import java.util.Base64;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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 static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.in;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -38,6 +50,24 @@ class StudyServiceTest {
@Mock
StudyRepository studyRepository;
+ @Mock
+ ParticipantService participantService;
+
+ @Mock
+ ObservationService observationService;
+
+ @Mock
+ InterventionService interventionService;
+
+ @Mock
+ IntegrationService integrationService;
+
+ @Mock
+ PushNotificationService pushNotificationService;
+
+ @Mock
+ ElasticService elasticService;
+
@Mock
StudyAclRepository studyAclRepository;
@@ -115,6 +145,9 @@ void testListStudies() {
void testSetStatus() {
testForbiddenSetStatus(Study.Status.DRAFT, Study.Status.DRAFT);
testForbiddenSetStatus(Study.Status.CLOSED, Study.Status.DRAFT);
+ testForbiddenSetStatus(Study.Status.PAUSED, Study.Status.DRAFT);
+ testForbiddenSetStatus(Study.Status.PAUSED, Study.Status.PAUSED_PREVIEW);
+ testForbiddenSetStatus(Study.Status.PREVIEW, Study.Status.CLOSED);
}
private void testForbiddenSetStatus(Study.Status statusBefore, Study.Status statusAfter) {
@@ -127,4 +160,75 @@ private void testForbiddenSetStatus(Study.Status statusBefore, Study.Status stat
Assertions.assertThrows(BadRequestException.class,
() -> studyService.setStatus(1L, statusAfter, currentUser));
}
+
+ @Test
+ void testWorkflowSideEffects() {
+ final Study study = new Study().setStudyId(1L)
+ .setContact(new Contact().setPerson("testPerson").setEmail("testMail"));
+ final List pt = List.of(
+ new Participant().setParticipantId(1).setStudyId(study.getStudyId()),
+ new Participant().setParticipantId(2).setStudyId(study.getStudyId())
+ );
+
+ when(studyRepository.getById(eq(study.getStudyId()), any())).thenReturn(Optional.of(study));
+ when(participantService.listParticipants(study.getStudyId())).thenReturn(pt);
+ when(studyRepository.setStateById(eq(study.getStudyId()), any()))
+ .thenAnswer(i -> Optional.of(study.setStudyState(i.getArgument(1))));
+
+ StudyService.VALID_STUDY_TRANSITIONS.forEach((from, tos) -> {
+ tos.forEach(to -> {
+ study.setStudyState(from);
+ clearInvocations(
+ observationService, interventionService, integrationService,
+ participantService, pushNotificationService, elasticService
+ );
+
+ studyService.setStatus(1L, to, currentUser);
+
+ verify(observationService,
+ times(1).description("%s -> %s should align observations".formatted(from, to))
+ ).alignObservationsWithStudyState(study);
+ verify(interventionService,
+ times(1).description("%s -> %s should align interventions".formatted(from, to))
+ ).alignInterventionsWithStudyState(study);
+ verify(integrationService,
+ times(1).description("%s -> %s should align integrations".formatted(from, to))
+ ).alignIntegrationsWithStudyState(study);
+ verify(participantService,
+ times(1).description("%s -> %s should align participants".formatted(from, to))
+ ).alignParticipantsWithStudyState(study);
+ verify(pushNotificationService,
+ times(pt.size()).description("%s -> %s should send %d notifications".formatted(from, to, pt.size()))
+ ).sendStudyStateUpdate(any(), any(), any());
+
+ // ONLY when transitioning to back to DRAFT, clear the collected data in Elastic
+ if ((from == Study.Status.PREVIEW || from == Study.Status.PAUSED_PREVIEW)
+ && to == Study.Status.DRAFT) {
+ verify(elasticService,
+ times(1).description("%s -> %s should delete the elastic index".formatted(from, to))
+ ).deleteIndex(study.getStudyId());
+ } else {
+ verify(elasticService,
+ never().description("%s -> %s must not delete the elastic index".formatted(from, to))
+ ).deleteIndex(study.getStudyId());
+ }
+ });
+
+ clearInvocations(
+ observationService, interventionService, integrationService,
+ participantService, pushNotificationService, elasticService
+ );
+ EnumSet.complementOf(EnumSet.copyOf(tos)).forEach(invalidTo -> {
+ study.setStudyState(from);
+ Assertions.assertThrows(BadRequestException.class,
+ () -> studyService.setStatus(1L, invalidTo, currentUser),
+ () -> "Invalid Transition: %s -> %s".formatted(from, invalidTo));
+ Mockito.verifyNoInteractions(
+ observationService, interventionService, integrationService,
+ participantService, pushNotificationService, elasticService
+ );
+ });
+
+ });
+ }
}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/transformer/ScheduleEventTransformerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/transformer/ScheduleEventTransformerTest.java
new file mode 100644
index 00000000..fd8bbbf1
--- /dev/null
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/transformer/ScheduleEventTransformerTest.java
@@ -0,0 +1,106 @@
+package io.redlink.more.studymanager.transformer;
+
+import io.redlink.more.studymanager.api.v1.model.EventDTO;
+import io.redlink.more.studymanager.api.v1.model.ObservationScheduleDTO;
+import io.redlink.more.studymanager.api.v1.model.RelativeEventDTO;
+import io.redlink.more.studymanager.model.scheduler.*;
+import io.redlink.more.studymanager.model.transformer.EventTransformer;
+import io.redlink.more.studymanager.utils.MapperUtils;
+import java.time.LocalTime;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+public class ScheduleEventTransformerTest {
+
+ @Test
+ public void testJsonToEventTransformer() {
+ String jsonEvent = "{\"rrule\": null, \"dateEnd\": 1683755999.000000000, \"dateStart\": 1683669600.000000000}";
+
+ String jsonRelativeEvent1 = """
+{
+ "type":"RelativeEvent",
+ "dtstart":{
+ "offset":{
+ "value":1,
+ "unit":"DAY"
+ },
+ "time":"12:00:00"
+ },
+ "dtend":{
+ "offset":{
+ "value":2,
+ "unit":"DAY"
+ },
+ "time":"12:00:00"
+ }
+}
+ """;
+
+ String jsonRelativeEvent2 = """
+{
+ "type":"RelativeEvent",
+ "dtstart":{
+ "offset":{
+ "value":1,
+ "unit":"DAY"
+ },
+ "time":"12:00:00"
+ },
+ "dtend":{
+ "offset":{
+ "value":2,
+ "unit":"DAY"
+ },
+ "time":"12:00:00"
+ },
+ "rrrule":{
+ "frequency":{
+ "unit":"DAY",
+ "value":1
+ },
+ "endAfter":{
+ "unit":"DAY",
+ "value":10
+ }
+ }
+}
+ """;
+
+ ScheduleEvent event = MapperUtils.readValue(jsonEvent, ScheduleEvent.class);
+ Assertions.assertInstanceOf(Event.class, event);
+
+ ScheduleEvent eventRelative1 = MapperUtils.readValue(jsonRelativeEvent1, ScheduleEvent.class);
+ Assertions.assertInstanceOf(RelativeEvent.class, eventRelative1);
+
+ ScheduleEvent eventRelative2 = MapperUtils.readValue(jsonRelativeEvent2, ScheduleEvent.class);
+ Assertions.assertInstanceOf(RelativeEvent.class, eventRelative2);
+ }
+
+ @Test
+ public void testDTOTransformer() {
+ Event event = new Event()
+ .setDateStart(Instant.now().plus(1, ChronoUnit.DAYS))
+ .setDateEnd(Instant.now().plus(2, ChronoUnit.DAYS));
+
+ RelativeEvent relativeEvent = new RelativeEvent()
+ .setDtstart(new RelativeDate()
+ .setTime(LocalTime.parse("12:00:00"))
+ .setOffset(new Duration().setUnit(Duration.Unit.DAY).setValue(3)))
+ .setDtend(new RelativeDate()
+ .setTime(LocalTime.parse("12:00:00"))
+ .setOffset(new Duration().setUnit(Duration.Unit.DAY).setValue(4)))
+ .setRrrule(new RelativeRecurrenceRule()
+ .setFrequency(new Duration().setUnit(Duration.Unit.DAY).setValue(1))
+ .setEndAfter(new Duration().setUnit(Duration.Unit.DAY).setValue(10)));
+
+ ObservationScheduleDTO eventDTO = EventTransformer.toObservationScheduleDTO_V1(event);
+ ObservationScheduleDTO relativeEventDTO = EventTransformer.toObservationScheduleDTO_V1(relativeEvent);
+
+ Assertions.assertInstanceOf(EventDTO.class, eventDTO);
+ Assertions.assertInstanceOf(RelativeEventDTO.class, relativeEventDTO);
+
+ }
+}
diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/utils/SchedulerUtilsTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/utils/SchedulerUtilsTest.java
new file mode 100644
index 00000000..140e196b
--- /dev/null
+++ b/studymanager/src/test/java/io/redlink/more/studymanager/utils/SchedulerUtilsTest.java
@@ -0,0 +1,62 @@
+package io.redlink.more.studymanager.utils;
+
+import io.redlink.more.studymanager.model.Observation;
+import io.redlink.more.studymanager.model.scheduler.Duration;
+import io.redlink.more.studymanager.model.scheduler.RelativeDate;
+import io.redlink.more.studymanager.model.scheduler.RelativeEvent;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SchedulerUtilsTest {
+
+
+ @Test
+ void alignStartDateToSignupInstant() {
+ final Observation observation = new Observation()
+ .setObservationId(1)
+ .setTitle("Early Test Observation")
+ .setSchedule(new RelativeEvent()
+ .setDtstart(new RelativeDate()
+ .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY))
+ .setTime(LocalTime.parse("08:00"))
+ )
+ .setDtend(new RelativeDate()
+ .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY))
+ .setTime(LocalTime.parse("09:00"))
+ )
+ );
+
+ final LocalDate today = LocalDate.now(ZoneId.systemDefault());
+ final Instant beforeObservation = today
+ .atTime(LocalTime.parse("07:30"))
+ .atZone(ZoneId.systemDefault())
+ .toInstant();
+ assertThat(SchedulerUtils.alignStartDateToSignupInstant(beforeObservation, List.of(observation)))
+ .as("Signup is at %s (before the observation ends), so we start immediately", beforeObservation)
+ .isEqualTo(today);
+
+ final Instant duringObservation = today
+ .atTime(LocalTime.parse("08:30"))
+ .atZone(ZoneId.systemDefault())
+ .toInstant();
+ assertThat(SchedulerUtils.alignStartDateToSignupInstant(duringObservation, List.of(observation)))
+ .as("Signup is at %s (before the observation ends), so we start immediately", duringObservation)
+ .isEqualTo(today);
+
+ final Instant afterObservation = today
+ .atTime(LocalTime.parse("09:30"))
+ .atZone(ZoneId.systemDefault())
+ .toInstant();
+ assertThat(SchedulerUtils.alignStartDateToSignupInstant(afterObservation, List.of(observation)))
+ .as("Signup is at %s (after the observation ends), so we start tomorrow", afterObservation)
+ .isEqualTo(today.plusDays(1));
+
+
+ }
+}
\ No newline at end of file