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/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java b/core/src/main/java/bio/terra/pearl/core/service/survey/SurveyResponseService.java index 0cde3d6ffa..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 @@ -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 shouldCreateNewLongitudinalTaskAndResponse(Integer createNewResponseAfterDays, Instant surveyResponseLastUpdatedAt) { + if(createNewResponseAfterDays == null) { + return false; + } + Instant cutoffTime = ZonedDateTime.now(ZoneOffset.UTC) + .minusDays(createNewResponseAfterDays).toInstant(); + return surveyResponseLastUpdatedAt.isBefore(cutoffTime); + } + /** * Creates a survey response and fires appropriate downstream events. */ @@ -152,8 +166,23 @@ 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 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 && 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 updatedAnswers = createOrUpdateAnswers(responseDto.getAnswers(), response, justification, survey, ppUser, operator); List allAnswers = new ArrayList<>(response.getAnswers()); @@ -189,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); } 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/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/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", 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 49a8078bea..fe94cc3447 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

) diff --git a/ui-core/src/components/forms/PagedSurveyView.tsx b/ui-core/src/components/forms/PagedSurveyView.tsx index 051d0d3f00..5386fa00af 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,8 @@ 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 * see this survey as 'in progress' and capture any profile changes. @@ -123,6 +128,15 @@ export function PagedSurveyView({ }) } + 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 (taskForResponse && taskId !== taskForResponse.id) { + setTaskId(taskForResponse.id) + } + } + const cancelAutosave = useAutosaveEffect(saveDiff, AUTO_SAVE_INTERVAL) /** Submit the response to the server */ diff --git a/ui-core/src/types/forms.ts b/ui-core/src/types/forms.ts index 309728c264..ddcb27965e 100644 --- a/ui-core/src/types/forms.ts +++ b/ui-core/src/types/forms.ts @@ -47,6 +47,7 @@ export type Survey = VersionedForm & { allowParticipantStart: boolean allowParticipantReedit: boolean prepopulate: boolean + createNewResponseAfterDays?: number eligibilityRule?: string } 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} />