From 2b7cac202b858767770d031698c802115b02b759 Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Fri, 14 Feb 2025 12:32:05 -0500 Subject: [PATCH 1/7] initial --- .../terra/pearl/core/model/survey/Survey.java | 1 + ...02_13_create_new_survey_response_days.yaml | 9 ++++++++ .../db/changelog/db.changelog-master.yaml | 4 ++++ .../src/study/surveys/FormOptionsModal.tsx | 23 +++++++++++++++---- ui-admin/src/study/surveys/SurveyView.tsx | 1 + ui-core/src/types/forms.ts | 1 + 6 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 core/src/main/resources/db/changelog/changesets/2025_02_13_create_new_survey_response_days.yaml diff --git a/core/src/main/java/bio/terra/pearl/core/model/survey/Survey.java b/core/src/main/java/bio/terra/pearl/core/model/survey/Survey.java index d5134746a9..8b1d810bb8 100644 --- a/core/src/main/java/bio/terra/pearl/core/model/survey/Survey.java +++ b/core/src/main/java/bio/terra/pearl/core/model/survey/Survey.java @@ -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 diff --git a/core/src/main/resources/db/changelog/changesets/2025_02_13_create_new_survey_response_days.yaml b/core/src/main/resources/db/changelog/changesets/2025_02_13_create_new_survey_response_days.yaml new file mode 100644 index 0000000000..66a7f9fb29 --- /dev/null +++ b/core/src/main/resources/db/changelog/changesets/2025_02_13_create_new_survey_response_days.yaml @@ -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 } diff --git a/core/src/main/resources/db/changelog/db.changelog-master.yaml b/core/src/main/resources/db/changelog/db.changelog-master.yaml index 6e16aeb128..800caef81d 100644 --- a/core/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/core/src/main/resources/db/changelog/db.changelog-master.yaml @@ -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 diff --git a/ui-admin/src/study/surveys/FormOptionsModal.tsx b/ui-admin/src/study/surveys/FormOptionsModal.tsx index 49a8078bea..b387e8d63b 100644 --- a/ui-admin/src/study/surveys/FormOptionsModal.tsx +++ b/ui-admin/src/study/surveys/FormOptionsModal.tsx @@ -180,16 +180,31 @@ export const FormOptions = ({ studyEnvContext, initialWorkingForm, updateWorking /> days - {workingForm.recurrenceType === 'LONGITUDINAL' && + + }

Eligibility Rule

Date: Fri, 14 Feb 2025 13:01:40 -0500 Subject: [PATCH 2/7] works, but a bit messy --- .../service/survey/SurveyResponseService.java | 42 +++++++++++++++---- .../service/survey/SurveyTaskDispatcher.java | 2 +- .../studies/heartdemo/surveys/lifestyle.json | 1 + 3 files changed, 36 insertions(+), 9 deletions(-) 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 0cde3d6ffa..ba6e277368 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 @@ -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; @@ -21,10 +18,15 @@ 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.*; +import java.util.stream.Collectors; @Service public class SurveyResponseService extends CrudService { @@ -34,6 +36,7 @@ public class SurveyResponseService extends CrudService getReferencedAnswers(Enrollee enrollee, Survey survey) { return answers; } + //if the cutoff time has passed, create a new task and response + private boolean shouldCreateNewLongtitudinalTaskAndResponse(Integer createNewResponseAfterDays, ParticipantTask task) { + if(createNewResponseAfterDays == null) { + return false; + } + Instant cutoffTime = ZonedDateTime.now(ZoneOffset.UTC) + .minusSeconds(createNewResponseAfterDays).toInstant(); //todo change back to minusDays + return task.getLastUpdatedAt().isBefore(cutoffTime); + } + /** * Creates a survey response and fires appropriate downstream events. */ @@ -152,8 +166,20 @@ public HubResponse 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 response; + //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 && shouldCreateNewLongtitudinalTaskAndResponse(survey.getCreateNewResponseAfterDays(), task)) { + ParticipantTask newTask = participantTaskService.cleanForCopying(task); + SurveyResponse priorResponse = dao.findOneWithAnswers(task.getSurveyResponseId()).orElseThrow(() -> new NotFoundException("No response found for task %s".formatted(taskId))); + 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 updatedAnswers = createOrUpdateAnswers(responseDto.getAnswers(), response, justification, survey, ppUser, operator); List allAnswers = new ArrayList<>(response.getAnswers()); diff --git a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java index e5a159f539..16b0eef743 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java +++ b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyTaskDispatcher.java @@ -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 answers = priorResponse.getAnswers().stream() .map(a -> (Answer) a.cleanForCopying()) .collect(Collectors.toList()); diff --git a/populate/src/main/resources/seed/portals/demo/studies/heartdemo/surveys/lifestyle.json b/populate/src/main/resources/seed/portals/demo/studies/heartdemo/surveys/lifestyle.json index 3a928627a4..d7b7c61562 100644 --- a/populate/src/main/resources/seed/portals/demo/studies/heartdemo/surveys/lifestyle.json +++ b/populate/src/main/resources/seed/portals/demo/studies/heartdemo/surveys/lifestyle.json @@ -5,6 +5,7 @@ "footer": "Resources used for this survey:\n * Tobacco Use Supplement to the Current Population Survey (TUS‑CPS)\n * American Thoracic Society Division of Lung Disease questionnaire (ATS ‑ DLD ‑ 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‑C)\n * NM ASSIST (NIDA-Modified Alcohol, Smoking, and Substance Involvement Screening Test)", "recurrenceIntervalDays": 7, "recurrenceType": "LONGITUDINAL", + "createNewResponseAfterDays": 1, "jsonContent": { "title": { "en": "Lifestyle", From 4ec263b1091260c3a893e7f479a2032411f0b1bd Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Fri, 14 Feb 2025 14:06:04 -0500 Subject: [PATCH 3/7] fix date --- .../core/service/survey/SurveyResponseService.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 ba6e277368..4f30bd2e9f 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 @@ -141,13 +141,13 @@ private List getReferencedAnswers(Enrollee enrollee, Survey survey) { } //if the cutoff time has passed, create a new task and response - private boolean shouldCreateNewLongtitudinalTaskAndResponse(Integer createNewResponseAfterDays, ParticipantTask task) { + private boolean shouldCreateNewLongitudinalTaskAndResponse(Integer createNewResponseAfterDays, Instant surveyResponseLastUpdatedAt) { if(createNewResponseAfterDays == null) { return false; } Instant cutoffTime = ZonedDateTime.now(ZoneOffset.UTC) - .minusSeconds(createNewResponseAfterDays).toInstant(); //todo change back to minusDays - return task.getLastUpdatedAt().isBefore(cutoffTime); + .minusDays(createNewResponseAfterDays).toInstant(); + return surveyResponseLastUpdatedAt.isBefore(cutoffTime); } /** @@ -167,11 +167,14 @@ public HubResponse updateResponse(SurveyResponse responseDto, Re validateResponse(survey, task, responseDto.getAnswers()); + SurveyResponse priorResponse = dao.findOneWithAnswers(task.getSurveyResponseId()).orElse(null); SurveyResponse response; //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 && shouldCreateNewLongtitudinalTaskAndResponse(survey.getCreateNewResponseAfterDays(), task)) { + if (survey.getRecurrenceType() == RecurrenceType.LONGITUDINAL && priorResponse != null && shouldCreateNewLongitudinalTaskAndResponse(survey.getCreateNewResponseAfterDays(), priorResponse.getLastUpdatedAt())) { ParticipantTask newTask = participantTaskService.cleanForCopying(task); - SurveyResponse priorResponse = dao.findOneWithAnswers(task.getSurveyResponseId()).orElseThrow(() -> new NotFoundException("No response found for task %s".formatted(taskId))); + if(newTask.getCompletedAt() != null) { + newTask.setCompletedAt(Instant.now()); + } priorResponse.getAnswers().forEach(a -> a.setSurveyResponseId(null)); response = surveyTaskDispatcher.createPrepopulatedSurveyResponse(priorResponse); newTask.setSurveyResponseId(response.getId()); From a3d957956c6025d3ae53f8202e8cac0423fbd06a Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 26 Feb 2025 13:33:45 -0500 Subject: [PATCH 4/7] add comment --- .../survey/SurveyResponseEditor.tsx | 3 ++- ui-admin/src/study/surveys/FormOptionsModal.tsx | 4 ++-- .../components/forms/PagedSurveyView.test.tsx | 2 +- .../src/components/forms/PagedSurveyView.tsx | 17 +++++++++++++++-- ui-participant/src/hub/survey/SurveyView.tsx | 5 +++-- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx b/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx index 4136668926..4ec36fed58 100644 --- a/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx +++ b/ui-admin/src/study/participants/survey/SurveyResponseEditor.tsx @@ -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 Task Id must be specified } @@ -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}/>
diff --git a/ui-admin/src/study/surveys/FormOptionsModal.tsx b/ui-admin/src/study/surveys/FormOptionsModal.tsx index b387e8d63b..fe94cc3447 100644 --- a/ui-admin/src/study/surveys/FormOptionsModal.tsx +++ b/ui-admin/src/study/surveys/FormOptionsModal.tsx @@ -200,8 +200,8 @@ export const FormOptions = ({ studyEnvContext, initialWorkingForm, updateWorking createNewResponseAfterDays: parseInt(val) })} /> days - If this value is set, a new response will be created for the participant if the make an edit to - their response after this many days + If this value is set, a new task and response will be created for the participant + if they make an edit to their response after this many days }/> } diff --git a/ui-core/src/components/forms/PagedSurveyView.test.tsx b/ui-core/src/components/forms/PagedSurveyView.test.tsx index fafa1bbb09..e063869383 100644 --- a/ui-core/src/components/forms/PagedSurveyView.test.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.test.tsx @@ -468,7 +468,7 @@ const setupSurveyTest = (survey: Survey, profile?: Profile, referencedAnswers?: ) diff --git a/ui-core/src/components/forms/PagedSurveyView.tsx b/ui-core/src/components/forms/PagedSurveyView.tsx index 051d0d3f00..331ed6bd66 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -21,7 +21,7 @@ import { SurveyAutoCompleteButton } from './SurveyAutoCompleteButton' import { SurveyReviewModeButton } from './ReviewModeButton' import { StudyEnvParams } from 'src/types/study' import { - Enrollee, + Enrollee, HubResponse, Profile } from 'src/types/user' import classNames from 'classnames' @@ -40,6 +40,7 @@ export function PagedSurveyView({ updateEnrollee, updateProfile, taskId, + setTaskId, selectedLanguage, justification, setAutosaveStatus, enrollee, proxyProfile, adminUserId, onSuccess, onFailure, showHeaders = true @@ -53,7 +54,9 @@ export function PagedSurveyView({ updateProfile: (profile: Profile, updateWithoutRerender?: boolean) => void, proxyProfile?: Profile, justification?: string, - taskId: string, adminUserId: string | null, enrollee: Enrollee, showHeaders?: boolean, + taskId: string, + setTaskId: (taskId: string) => void, + adminUserId: string | null, enrollee: Enrollee, showHeaders?: boolean, }) { const resumableData = makeSurveyJsData(response?.resumeData, response?.answers, enrollee.participantUserId) const pager = useRoutablePageNumber() @@ -102,6 +105,7 @@ export function PagedSurveyView({ participantTasks: response.tasks, profile: response.profile } + updateTaskId(response) /** * CAREFUL -- we're updating the enrollee object so that if they navigate back to the dashboard, they'll * see this survey as 'in progress' and capture any profile changes. @@ -123,6 +127,15 @@ export function PagedSurveyView({ }) } + const updateTaskId = (response: HubResponse) => { + //TODO: can we trust that the first task returned is the newest? hmmm + const newestTask = response.tasks[0] + //set url params to the newest task id + if (newestTask && taskId !== newestTask.id) { + setTaskId(newestTask.id) + } + } + const cancelAutosave = useAutosaveEffect(saveDiff, AUTO_SAVE_INTERVAL) /** Submit the response to the server */ diff --git a/ui-participant/src/hub/survey/SurveyView.tsx b/ui-participant/src/hub/survey/SurveyView.tsx index 07822b91c9..ca6201f9ad 100644 --- a/ui-participant/src/hub/survey/SurveyView.tsx +++ b/ui-participant/src/hub/survey/SurveyView.tsx @@ -59,7 +59,7 @@ function SurveyView({ showHeaders = true }: { showHeaders?: boolean }) { ?.profile : undefined const { i18n, selectedLanguage } = useI18n() - const { taskId } = useTaskIdParam() ?? '' + const { taskId, setTaskId } = useTaskIdParam() const navigate = useNavigate() if (!stableId || !version || !studyShortcode) { @@ -130,7 +130,8 @@ function SurveyView({ showHeaders = true }: { showHeaders?: boolean }) { onFailure={onFailure} updateEnrollee={updateEnrollee} updateProfile={updateProfile} - taskId={taskId} + taskId={taskId || ''} + setTaskId={setTaskId} showHeaders={showHeaders} /> From f9a0826fc33992c876bafe863a45e49da62ce20a Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 26 Feb 2025 14:23:05 -0500 Subject: [PATCH 5/7] cleaner --- ui-core/src/components/forms/PagedSurveyView.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui-core/src/components/forms/PagedSurveyView.tsx b/ui-core/src/components/forms/PagedSurveyView.tsx index 331ed6bd66..844baf3e0b 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -127,12 +127,12 @@ export function PagedSurveyView({ }) } - const updateTaskId = (response: HubResponse) => { - //TODO: can we trust that the first task returned is the newest? hmmm - const newestTask = response.tasks[0] + const updateTaskId = (hubResponse: HubResponse) => { + const surveyResponseId = hubResponse.response.id + const taskForResponse = hubResponse.tasks.find(task => task.surveyResponseId === surveyResponseId) //set url params to the newest task id - if (newestTask && taskId !== newestTask.id) { - setTaskId(newestTask.id) + if (taskForResponse && taskId !== taskForResponse.id) { + setTaskId(taskForResponse.id) } } From b41564696107e1e29b23392a79451024648e32b6 Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Wed, 19 Mar 2025 13:24:27 -0400 Subject: [PATCH 6/7] actually attach tasks --- .../terra/pearl/core/service/survey/SurveyResponseService.java | 2 ++ 1 file changed, 2 insertions(+) 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 4f30bd2e9f..0adb995dd1 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 @@ -218,6 +218,8 @@ public HubResponse 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); } From 1f8027261850c344e1d15f1e25dfee4bb6ea0f00 Mon Sep 17 00:00:00 2001 From: Matt Bemis Date: Thu, 27 Mar 2025 15:50:49 -0400 Subject: [PATCH 7/7] Update ui-core/src/components/forms/PagedSurveyView.tsx Co-authored-by: Devon --- ui-core/src/components/forms/PagedSurveyView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui-core/src/components/forms/PagedSurveyView.tsx b/ui-core/src/components/forms/PagedSurveyView.tsx index 844baf3e0b..5386fa00af 100644 --- a/ui-core/src/components/forms/PagedSurveyView.tsx +++ b/ui-core/src/components/forms/PagedSurveyView.tsx @@ -105,6 +105,7 @@ export function PagedSurveyView({ participantTasks: response.tasks, profile: response.profile } + // update the taskId in case this is an update to a longitudinal survey which will create a new task & response updateTaskId(response) /** * CAREFUL -- we're updating the enrollee object so that if they navigate back to the dashboard, they'll