Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

224: Study-Preview Mode #277

Merged
merged 2 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,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;

Expand All @@ -47,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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
*/
package io.redlink.more.studymanager.model.transformer;

import io.redlink.more.studymanager.api.v1.model.*;
import io.redlink.more.studymanager.api.v1.model.ContactDTO;
import io.redlink.more.studymanager.api.v1.model.StatusChangeDTO;
import io.redlink.more.studymanager.api.v1.model.StudyDTO;
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;

import java.util.Optional;

public class StudyTransformer {

private StudyTransformer() {}
Expand Down Expand Up @@ -60,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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,13 +25,18 @@
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 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 =
Expand Down Expand Up @@ -132,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<String> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -69,4 +70,12 @@ public static String toParam(Participant.Status status) {
case LOCKED -> "locked";
};
}

public static RowMapper<Integer> intReader(String columnLabel) {
return (rs, rowNum) -> {
int anInt = rs.getInt(columnLabel);
if (rs.wasNull()) return null;
return anInt;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,14 @@ public class StudyRepository {

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;
Expand Down Expand Up @@ -116,17 +120,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<Study> 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) {
Expand Down Expand Up @@ -161,7 +177,7 @@ private static RowMapper<Study> getStudyRowMapper() {
.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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
Expand Down Expand Up @@ -265,7 +266,12 @@ public List<ParticipationData> getParticipationData(Long studyId){
}
return participationDataList;
}catch (IOException | ElasticsearchException e) {
LOG.error("Elastic Query failed", e);
if (e instanceof ElasticsearchException ee) {
if (Objects.equals(ee.error().type(), "index_not_found_exception")) {
return List.of();
}
}
LOG.warn("Elastic Query failed", e);
return new ArrayList<>();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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;
Expand Down Expand Up @@ -152,7 +153,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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;
Expand Down Expand Up @@ -79,7 +80,7 @@ public Observation updateObservation(Observation 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand Down Expand Up @@ -66,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,21 @@
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);

private static final Map<Study.Status, Set<Study.Status>> 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;
Expand Down Expand Up @@ -91,52 +101,49 @@ 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.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())
)
);
participantService.alignParticipantsWithStudyState(s);
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",e);
}
});
}

// every minute
@Scheduled(cron = "0 * * * * ?")
public void closeParticipationsForStudiesWithDurations() {
List<Participant> participantsToClose = participantService.listParticipantsForClosing();
log.debug("Selected {} paticipants to close", participantsToClose.size());
log.debug("Selected {} participants to close", participantsToClose.size());
participantsToClose.forEach(participant -> {
pushNotificationService.sendPushNotification(
participant.getStudyId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TYPE study_state ADD VALUE 'preview';
ALTER TYPE study_state ADD VALUE 'paused-preview';
Loading
Loading