diff --git a/core/src/main/java/bio/terra/pearl/core/dao/survey/SurveyResponseDao.java b/core/src/main/java/bio/terra/pearl/core/dao/survey/SurveyResponseDao.java index 98249162e7..25ff082f1d 100644 --- a/core/src/main/java/bio/terra/pearl/core/dao/survey/SurveyResponseDao.java +++ b/core/src/main/java/bio/terra/pearl/core/dao/survey/SurveyResponseDao.java @@ -102,4 +102,13 @@ public Optional findMostRecent(UUID enrolleeId, UUID surveyId) { .findOne() ); } + + public List findAllByEnrolleeAndSurveyId( + UUID enrolleeId, UUID surveyId) { + return findAllByTwoProperties( + "enrollee_id", enrolleeId, + "survey_id", surveyId + ); + + } } diff --git a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java index 0adb995dd1..9392e1e021 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java +++ b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java @@ -26,7 +26,6 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.*; -import java.util.stream.Collectors; @Service public class SurveyResponseService extends CrudService { @@ -150,6 +149,18 @@ private boolean shouldCreateNewLongitudinalTaskAndResponse(Integer createNewResp return surveyResponseLastUpdatedAt.isBefore(cutoffTime); } + private boolean isMostRecentResponse(SurveyResponse surveyResponse) { + List 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. */ @@ -169,8 +180,23 @@ public HubResponse updateResponse(SurveyResponse responseDto, Re SurveyResponse priorResponse = dao.findOneWithAnswers(task.getSurveyResponseId()).orElse(null); SurveyResponse response; + + if (survey.getRecurrenceType() == RecurrenceType.LONGITUDINAL + && priorResponse != null + && !isMostRecentResponse(priorResponse) + // admins should be able to update old responses + && operator.getParticipantUser() != null) { + 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())) { + if (survey.getRecurrenceType() == RecurrenceType.LONGITUDINAL + && priorResponse != null + && shouldCreateNewLongitudinalTaskAndResponse(survey.getCreateNewResponseAfterDays(), priorResponse.getLastUpdatedAt()) + // admin saving update should never trigger a new response; + // they can re-assign if they want a fresh response + && operator.getParticipantUser() != null + ) { ParticipantTask newTask = participantTaskService.cleanForCopying(task); if(newTask.getCompletedAt() != null) { newTask.setCompletedAt(Instant.now()); diff --git a/core/src/test/java/bio/terra/pearl/core/service/survey/SurveyResponseServiceTests.java b/core/src/test/java/bio/terra/pearl/core/service/survey/SurveyResponseServiceTests.java index a48a43ac0f..8a3e5fe4f4 100644 --- a/core/src/test/java/bio/terra/pearl/core/service/survey/SurveyResponseServiceTests.java +++ b/core/src/test/java/bio/terra/pearl/core/service/survey/SurveyResponseServiceTests.java @@ -12,6 +12,7 @@ import bio.terra.pearl.core.factory.survey.SurveyFactory; import bio.terra.pearl.core.factory.survey.SurveyResponseFactory; import bio.terra.pearl.core.model.EnvironmentName; +import bio.terra.pearl.core.model.admin.AdminUser; import bio.terra.pearl.core.model.audit.ParticipantDataChange; import bio.terra.pearl.core.model.audit.ResponsibleEntity; import bio.terra.pearl.core.model.file.ParticipantFile; @@ -19,7 +20,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; @@ -32,12 +35,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 @@ -367,4 +373,181 @@ 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 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 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 testLongitudinalDoesNotSaveEditAsNewTaskForAdmins(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 enrolleeBundle2 = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv()); + Enrollee enrollee2 = enrolleeBundle2.enrollee(); + + + 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 newResponse2 = SurveyResponse.builder() + .enrolleeId(enrollee2.getId()) + .creatingParticipantUserId(enrollee2.getParticipantUserId()) + .surveyId(survey.getId()) + .lastUpdatedAt(Instant.now()) + .build(); + + // update response2 + HubResponse hubResponse2 = surveyResponseService.updateResponse( + newResponse2, new ResponsibleEntity(new AdminUser()), "test", + enrolleeBundle2.portalParticipantUser(), enrollee2, task2.getId(), survey.getPortalId()); + + ParticipantTask responseTask2 = hubResponse2.getTasks().stream().filter(t -> t.getSurveyResponseId().equals(hubResponse2.getResponse().getId())).findFirst().get(); + + // did not create a new task + assertThat(responseTask2.getId(), 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()); + + // doesn't throw if admin updates old response + surveyResponseService.updateResponse( + newResponse, new ResponsibleEntity(new AdminUser()), null, + enrolleeBundle.portalParticipantUser(), enrollee, task1.getId(), survey.getPortalId()); + } + } diff --git a/ui-core/src/components/forms/PagedSurveyView.tsx b/ui-core/src/components/forms/PagedSurveyView.tsx index 5386fa00af..294b6a4db0 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -21,10 +21,12 @@ import { SurveyAutoCompleteButton } from './SurveyAutoCompleteButton' import { SurveyReviewModeButton } from './ReviewModeButton' import { StudyEnvParams } from 'src/types/study' import { - Enrollee, HubResponse, + Enrollee, + HubResponse, Profile } from 'src/types/user' import classNames from 'classnames' +import { isNil } from 'lodash' const AUTO_SAVE_INTERVAL = 3 * 1000 // auto-save every 3 seconds if there are changes @@ -180,6 +182,27 @@ export function PagedSurveyView({ } } + const shouldBeReadonly = () => { + if (form.recurrenceType === 'LONGITUDINAL' && isNil(adminUserId)) { + const tasks = enrollee + .participantTasks + .filter(task => task.targetStableId === form.stableId) + + if (tasks.length > 0) { + const latestTask = tasks.reduce( + ( + a, b + ) => a.completedAt && b.completedAt && a.completedAt > b.completedAt ? a : b) + + // if the task is not the latest task, then it should be readonly + if (taskId != latestTask.id) { + return true + } + } + } + return false + } + const { surveyModel, refreshSurvey } = useSurveyJSModel( form, resumableData, onComplete, pager, { studyEnvParams, @@ -188,7 +211,8 @@ export function PagedSurveyView({ proxyProfile, referencedAnswers, extraVariables: {} - } + }, + { readonly: shouldBeReadonly() } ) surveyModel.locale = selectedLanguage diff --git a/ui-core/src/surveyUtils.tsx b/ui-core/src/surveyUtils.tsx index 9a53269816..752448ff20 100644 --- a/ui-core/src/surveyUtils.tsx +++ b/ui-core/src/surveyUtils.tsx @@ -347,6 +347,7 @@ export function useRoutablePageNumber(): PageNumberControl { type UseSurveyJsModelOpts = { extraCssClasses?: Record, + readonly?: boolean } /** @@ -383,7 +384,9 @@ export function useSurveyJSModel( const Api = useApiContext() const { i18n } = useI18n() - const [surveyModel, setSurveyModel] = useState(newSurveyJSModel(resumeData, pager.pageNumber)) + const [surveyModel, setSurveyModel] = useState(newSurveyJSModel( + resumeData, pager.pageNumber, opts.readonly + )) /** hand a page change by updating state of both the surveyJS model and our internal state*/ function handlePageChanged(model: SurveyModel, options: any) { // eslint-disable-line @typescript-eslint/no-explicit-any, max-len @@ -392,7 +395,10 @@ export function useSurveyJSModel( } /** returns a surveyJS survey model with the given data/pageNumber */ - function newSurveyJSModel(refreshData: SurveyJsResumeData | null, pagerPageNumber: number | null) { + function newSurveyJSModel( + refreshData: SurveyJsResumeData | null, + pagerPageNumber: number | null, + readonly?: boolean) { const newSurveyModel = surveyJSModelFromForm(form) Object.entries(extraCssClasses).forEach(([elementPath, className]) => { @@ -422,6 +428,10 @@ export function useSurveyJSModel( newSurveyModel.onTextMarkdown.add(applyMarkdown) newSurveyModel.completedHtml = '
' // the application UX will handle showing any needed messages newSurveyModel.onServerValidateQuestions.add(createAddressValidator(addr => Api.validateAddress(addr), i18n)) + + if (readonly) { + newSurveyModel.mode = 'display' + } return newSurveyModel }