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} ${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 @@ ${project.build.directory}/generated-sources/study-manager 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 - ${project.build.directory}/generated-sources/app - 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 - ${project.build.directory}/generated-sources/external - 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