Skip to content

[JN-1651] previous responses read only #1552

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

Merged
merged 21 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from 18 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 @@ -102,4 +102,13 @@ public Optional<SurveyResponse> findMostRecent(UUID enrolleeId, UUID surveyId) {
.findOne()
);
}

public List<SurveyResponse> findAllByEnrolleeAndSurveyId(
UUID enrolleeId, UUID surveyId) {
return findAllByTwoProperties(
"enrollee_id", enrolleeId,
"survey_id", surveyId
);

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public class Survey extends BaseEntity implements Versioned, PortalAttached {
private boolean allowParticipantReedit = true; // whether participants can change answers after submission
@Builder.Default
private boolean prepopulate = false; // whether to bring forward answers from prior completions (if recurrence type is LONGITUDINAL)
private Integer createNewResponseAfterDays; // how many days after the last completion to create a separate new response if edits are made (if recurrence type is LONGITUDINAL)
@Builder.Default
private boolean autoAssign = true; // whether to assign the survey to enrollees automatically once they meet the eligibility criteria
@Builder.Default
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@
import bio.terra.pearl.core.model.participant.Enrollee;
import bio.terra.pearl.core.model.participant.PortalParticipantUser;
import bio.terra.pearl.core.model.survey.*;
import bio.terra.pearl.core.model.workflow.HubResponse;
import bio.terra.pearl.core.model.workflow.ParticipantTask;
import bio.terra.pearl.core.model.workflow.TaskStatus;
import bio.terra.pearl.core.model.workflow.TaskType;
import bio.terra.pearl.core.model.workflow.*;
import bio.terra.pearl.core.service.CascadeProperty;
import bio.terra.pearl.core.service.CrudService;
import bio.terra.pearl.core.service.exception.NotFoundException;
Expand All @@ -21,9 +18,13 @@
import bio.terra.pearl.core.service.workflow.EventService;
import bio.terra.pearl.core.service.workflow.ParticipantDataChangeService;
import bio.terra.pearl.core.service.workflow.ParticipantTaskService;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.*;

@Service
Expand All @@ -34,6 +35,7 @@ public class SurveyResponseService extends CrudService<SurveyResponse, SurveyRes
private final StudyEnvironmentSurveyService studyEnvironmentSurveyService;
private final AnswerProcessingService answerProcessingService;
private final ParticipantDataChangeService participantDataChangeService;
private final SurveyTaskDispatcher surveyTaskDispatcher;
private final EventService eventService;
private final EnrolleeContextService enrolleeContextService;
public static final String CONSENTED_ANSWER_STABLE_ID = "consented";
Expand All @@ -44,15 +46,16 @@ public SurveyResponseService(SurveyResponseDao dao,
ParticipantTaskService participantTaskService,
StudyEnvironmentSurveyService studyEnvironmentSurveyService,
AnswerProcessingService answerProcessingService,
ParticipantDataChangeService participantDataChangeService,
EventService eventService, EnrolleeContextService enrolleeContextService) {
EnrolleeContextService enrolleeContextService,
ParticipantDataChangeService participantDataChangeService, @Lazy SurveyTaskDispatcher surveyTaskDispatcher, EventService eventService) {
super(dao);
this.answerService = answerService;
this.surveyService = surveyService;
this.participantTaskService = participantTaskService;
this.studyEnvironmentSurveyService = studyEnvironmentSurveyService;
this.answerProcessingService = answerProcessingService;
this.participantDataChangeService = participantDataChangeService;
this.surveyTaskDispatcher = surveyTaskDispatcher;
this.eventService = eventService;
this.enrolleeContextService = enrolleeContextService;
}
Expand Down Expand Up @@ -136,6 +139,28 @@ private List<Answer> getReferencedAnswers(Enrollee enrollee, Survey survey) {
return answers;
}

//if the cutoff time has passed, create a new task and response
private boolean shouldCreateNewLongitudinalTaskAndResponse(Integer createNewResponseAfterDays, Instant surveyResponseLastUpdatedAt) {
if(createNewResponseAfterDays == null) {
return false;
}
Instant cutoffTime = ZonedDateTime.now(ZoneOffset.UTC)
.minusDays(createNewResponseAfterDays).toInstant();
return surveyResponseLastUpdatedAt.isBefore(cutoffTime);
}

private boolean isMostRecentResponse(SurveyResponse surveyResponse) {
List<SurveyResponse> surveyResponses = dao.findAllByEnrolleeAndSurveyId(
surveyResponse.getEnrolleeId(), surveyResponse.getSurveyId());

SurveyResponse latest = surveyResponses
.stream()
.max(Comparator.comparing(SurveyResponse::getCreatedAt))
.orElseThrow();

return surveyResponse.getId().equals(latest.getId());
}

/**
* Creates a survey response and fires appropriate downstream events.
*/
Expand All @@ -152,8 +177,30 @@ public HubResponse<SurveyResponse> updateResponse(SurveyResponse responseDto, Re
task.getTargetAssignedVersion(), portalId).get();
validateResponse(survey, task, responseDto.getAnswers());

// find or create the SurveyResponse object to attach the snapshot
SurveyResponse response = findOrCreateResponse(task, enrollee, enrollee.getParticipantUserId(), responseDto, portalId, operator);

SurveyResponse priorResponse = dao.findOneWithAnswers(task.getSurveyResponseId()).orElse(null);
SurveyResponse response;

if (survey.getRecurrenceType() == RecurrenceType.LONGITUDINAL
&& priorResponse != null
&& !isMostRecentResponse(priorResponse)) {
throw new IllegalArgumentException("Cannot update previous responses for longitudinal surveys");
}

//if the survey is longitudinal and we're past the cutoff point for updating an existing response, we need to create a new response and task
if (survey.getRecurrenceType() == RecurrenceType.LONGITUDINAL && priorResponse != null && shouldCreateNewLongitudinalTaskAndResponse(survey.getCreateNewResponseAfterDays(), priorResponse.getLastUpdatedAt())) {
ParticipantTask newTask = participantTaskService.cleanForCopying(task);
if(newTask.getCompletedAt() != null) {
newTask.setCompletedAt(Instant.now());
}
priorResponse.getAnswers().forEach(a -> a.setSurveyResponseId(null));
response = surveyTaskDispatcher.createPrepopulatedSurveyResponse(priorResponse);
newTask.setSurveyResponseId(response.getId());
task = participantTaskService.create(newTask, null);
} else {
// find or create the SurveyResponse object to attach the snapshot
response = findOrCreateResponse(task, enrollee, enrollee.getParticipantUserId(), responseDto, portalId, operator);
}

List<Answer> updatedAnswers = createOrUpdateAnswers(responseDto.getAnswers(), response, justification, survey, ppUser, operator);
List<Answer> allAnswers = new ArrayList<>(response.getAnswers());
Expand Down Expand Up @@ -189,6 +236,8 @@ public HubResponse<SurveyResponse> updateResponse(SurveyResponse responseDto, Re
EnrolleeSurveyEvent event = eventService.publishEnrolleeSurveyEvent(enrollee, response, ppUser, task);
enrolleeContext = event.getEnrolleeContext();
} else {
enrollee.getParticipantTasks().clear();
enrollee.getParticipantTasks().addAll(participantTaskService.findByEnrolleeId(enrollee.getId()));
enrolleeContext = enrolleeContextService.fetchData(enrollee);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public void copyTaskData(ParticipantTask newTask, ParticipantTask oldTask, Surve

// creates a new survey response with the same answers as priorResponse
// for use with longitudinal recurring surveys
private SurveyResponse createPrepopulatedSurveyResponse(SurveyResponse priorResponse) {
protected SurveyResponse createPrepopulatedSurveyResponse(SurveyResponse priorResponse) {
List<Answer> answers = priorResponse.getAnswers().stream()
.map(a -> (Answer) a.cleanForCopying())
.collect(Collectors.toList());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
databaseChangeLog:
- changeSet:
id: "create_new_survey_response_days"
author: mbemis
changes:
- addColumn:
tableName: survey
columns:
- column: { name: create_new_response_after_days, type: integer }
4 changes: 4 additions & 0 deletions core/src/main/resources/db/changelog/db.changelog-master.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,14 @@ databaseChangeLog:
- include:
file: changesets/2025_01_27_prenroll_type.yaml
relativeToChangelogFile: true
- include:
file: changesets/2025_02_13_create_new_survey_response_days.yaml
relativeToChangelogFile: true
- include:
file: changesets/2025_02_14_trigger_admin_email_filter.yaml
relativeToChangelogFile: true


# README: it is a best practice to put each DDL statement in its own change set. DDL statements
# are atomic. When they are grouped in a changeset and one fails the changeset cannot be
# rolled back or rerun making recovery more difficult
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
import bio.terra.pearl.core.model.participant.ParticipantUser;
import bio.terra.pearl.core.model.participant.PortalParticipantUser;
import bio.terra.pearl.core.model.survey.*;
import bio.terra.pearl.core.model.workflow.HubResponse;
import bio.terra.pearl.core.model.workflow.ParticipantTask;
import bio.terra.pearl.core.model.workflow.RecurrenceType;
import bio.terra.pearl.core.model.workflow.TaskStatus;
import bio.terra.pearl.core.service.file.ParticipantFileService;
import bio.terra.pearl.core.service.participant.EnrolleeService;
Expand All @@ -32,12 +34,15 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class SurveyResponseServiceTests extends BaseSpringBootTest {
@Autowired
Expand Down Expand Up @@ -367,4 +372,133 @@ public void testCompletedSurveyResponseCannotBeUpdatedToIncomplete(TestInfo test
assertThat(savedResponse.isComplete(), equalTo(true));
}

@Test
@Transactional
public void testLongitudinalSavesEditAsNewTask(TestInfo info) {
StudyEnvironmentBundle studyEnvBundle = studyEnvironmentFactory.buildBundle(getTestName(info), EnvironmentName.sandbox);

Survey survey = surveyFactory.buildPersisted(surveyFactory.builder(getTestName(info))
.portalId(studyEnvBundle.getPortal().getId())
.recurrenceType(RecurrenceType.LONGITUDINAL)
.createNewResponseAfterDays(7));

StudyEnvironmentSurvey ses = surveyFactory.attachToEnv(survey, studyEnvBundle.getStudyEnv().getId(), true);

EnrolleeBundle enrolleeBundle1 = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv());
Enrollee enrollee1 = enrolleeBundle1.enrollee();
EnrolleeBundle enrolleeBundle2 = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv());
Enrollee enrollee2 = enrolleeBundle2.enrollee();


SurveyResponse response1 = surveyResponseService.create(SurveyResponse.builder()
.enrolleeId(enrollee1.getId())
.creatingParticipantUserId(enrollee1.getParticipantUserId())
.surveyId(survey.getId())
.lastUpdatedAt(Instant.now().minus(1, ChronoUnit.DAYS))
.build());
ParticipantTask task1 = surveyTaskDispatcher.buildTask(enrolleeBundle1.enrollee(), enrolleeBundle1.portalParticipantUser(), new SurveyTaskConfigDto(ses));
task1.setSurveyResponseId(response1.getId());
task1 = participantTaskService.create(task1, getAuditInfo(info));


SurveyResponse response2 = surveyResponseService.create(SurveyResponse.builder()
.enrolleeId(enrollee2.getId())
.creatingParticipantUserId(enrollee2.getParticipantUserId())
.surveyId(survey.getId())
.lastUpdatedAt(Instant.now().minus(8, ChronoUnit.DAYS))
.build());
ParticipantTask task2 = surveyTaskDispatcher.buildTask(enrolleeBundle2.enrollee(), enrolleeBundle2.portalParticipantUser(), new SurveyTaskConfigDto(ses));
task2.setSurveyResponseId(response2.getId());
task2 = participantTaskService.create(task2, getAuditInfo(info));

SurveyResponse newResponse1 = SurveyResponse.builder()
.enrolleeId(enrollee1.getId())
.creatingParticipantUserId(enrollee1.getParticipantUserId())
.surveyId(survey.getId())
.lastUpdatedAt(Instant.now())
.build();
// update response1
HubResponse<SurveyResponse> hubResponse = surveyResponseService.updateResponse(
newResponse1, new ResponsibleEntity(enrolleeBundle1.participantUser()), null,
enrolleeBundle1.portalParticipantUser(), enrollee1, task1.getId(), survey.getPortalId());

ParticipantTask responseTask = hubResponse.getTasks().stream().filter(t -> t.getSurveyResponseId().equals(hubResponse.getResponse().getId())).findFirst().get();

// did not create a new task
assertThat(responseTask.getId(), equalTo(task1.getId()));

SurveyResponse newResponse2 = SurveyResponse.builder()
.enrolleeId(enrollee2.getId())
.creatingParticipantUserId(enrollee2.getParticipantUserId())
.surveyId(survey.getId())
.lastUpdatedAt(Instant.now())
.build();

// update response2
HubResponse<SurveyResponse> hubResponse2 = surveyResponseService.updateResponse(
newResponse2, new ResponsibleEntity(enrolleeBundle2.participantUser()), null,
enrolleeBundle2.portalParticipantUser(), enrollee2, task2.getId(), survey.getPortalId());

ParticipantTask responseTask2 = hubResponse2.getTasks().stream().filter(t -> t.getSurveyResponseId().equals(hubResponse2.getResponse().getId())).findFirst().get();

// created a new task
assertThat(responseTask2.getId(), not(equalTo(task2.getId())));
}

@Test
@Transactional
public void testCannotUpdatePreviousLongitudinalResponses(TestInfo info) {
StudyEnvironmentBundle studyEnvBundle = studyEnvironmentFactory.buildBundle(getTestName(info), EnvironmentName.sandbox);

Survey survey = surveyFactory.buildPersisted(surveyFactory.builder(getTestName(info))
.portalId(studyEnvBundle.getPortal().getId())
.recurrenceType(RecurrenceType.LONGITUDINAL)
.createNewResponseAfterDays(7));

StudyEnvironmentSurvey ses = surveyFactory.attachToEnv(survey, studyEnvBundle.getStudyEnv().getId(), true);

EnrolleeBundle enrolleeBundle = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv());
Enrollee enrollee = enrolleeBundle.enrollee();

SurveyResponse response1 = surveyResponseService.create(SurveyResponse.builder()
.enrolleeId(enrollee.getId())
.creatingParticipantUserId(enrollee.getParticipantUserId())
.surveyId(survey.getId())
.lastUpdatedAt(Instant.now().minus(1, ChronoUnit.DAYS))
.build());
ParticipantTask task1 = surveyTaskDispatcher.buildTask(enrolleeBundle.enrollee(), enrolleeBundle.portalParticipantUser(), new SurveyTaskConfigDto(ses));
task1.setSurveyResponseId(response1.getId());
task1 = participantTaskService.create(task1, getAuditInfo(info));

SurveyResponse response2 = surveyResponseService.create(SurveyResponse.builder()
.enrolleeId(enrollee.getId())
.creatingParticipantUserId(enrollee.getParticipantUserId())
.surveyId(survey.getId())
.lastUpdatedAt(Instant.now().minus(8, ChronoUnit.DAYS))
.build());
ParticipantTask task2 = surveyTaskDispatcher.buildTask(enrolleeBundle.enrollee(), enrolleeBundle.portalParticipantUser(), new SurveyTaskConfigDto(ses));
task2.setSurveyResponseId(response2.getId());
task2 = participantTaskService.create(task2, getAuditInfo(info));

SurveyResponse newResponse = SurveyResponse.builder()
.enrolleeId(enrollee.getId())
.creatingParticipantUserId(enrollee.getParticipantUserId())
.surveyId(survey.getId())
.lastUpdatedAt(Instant.now())
.build();

// throws updating old response
ParticipantTask finalTask1 = task1;
assertThrows(IllegalArgumentException.class, () -> {
surveyResponseService.updateResponse(
newResponse, new ResponsibleEntity(enrolleeBundle.participantUser()), null,
enrolleeBundle.portalParticipantUser(), enrollee, finalTask1.getId(), survey.getPortalId());
});
// doesn't throw updating newest
surveyResponseService.updateResponse(
newResponse, new ResponsibleEntity(enrolleeBundle.participantUser()), null,
enrolleeBundle.portalParticipantUser(), enrollee, task2.getId(), survey.getPortalId());

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"footer": "Resources used for this survey:\n * Tobacco Use Supplement to the Current Population Survey (TUS&#8209;CPS)\n * American Thoracic Society Division of Lung Disease questionnaire (ATS&nbsp;&#8209;&nbsp;DLD&nbsp;&#8209;&nbsp;78)\n * Million Veterans Program\n * Prostate, Lung, Colorectal, and Ovarian (PLCO) Cancer Screening Trial\n * Population Assessment of Tobacco and Health (PATH)\n * National Epidemiologic Survey on Alcohol and Related Conditions (NESARC)\n * Alcohol Use Disorders Identification Test (AUDIT&#8209;C)\n * NM ASSIST (NIDA-Modified Alcohol, Smoking, and Substance Involvement Screening Test)",
"recurrenceIntervalDays": 7,
"recurrenceType": "LONGITUDINAL",
"createNewResponseAfterDays": 1,
"jsonContent": {
"title": {
"en": "Lifestyle",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function SurveyResponseEditor({
updateResponseMap: (stableId: string, response: SurveyResponse) => void, justification?: string
}) {
const { defaultLanguage } = usePortalLanguage()
const { taskId } = useTaskIdParam()
const { taskId, setTaskId } = useTaskIdParam()
if (!taskId) {
return <span>Task Id must be specified</span>
}
Expand Down Expand Up @@ -57,6 +57,7 @@ export default function SurveyResponseEditor({
updateProfile={() => { /*no-op for admins*/ }}
updateEnrollee={() => { /*no-op for admins*/ }}
taskId={taskId}
setTaskId={setTaskId}
justification={justification}
showHeaders={false}/>
</div>
Expand Down
Loading
Loading