Skip to content

[JN-1619] Create new response and task for user if response was considered stale #1483

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 7 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -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,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<SurveyResponse, SurveyResponseDao> {
Expand All @@ -34,6 +36,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 +47,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 +140,16 @@ 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);
}

Comment on lines +144 to +152
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about this ticket and another point of trickiness that I think we need to account for is - what happens if you want to edit a previous response?

Like, let's say you have 5 responses, and you realize the second one is incorrect - if you edit that second one, I'm assuming it should edit in place. But if you attempt to edit the latest one, it should make a new one.

I dunno if that's the right approach, but it feels right to me.

/**
* Creates a survey response and fires appropriate downstream events.
*/
Expand All @@ -152,8 +166,23 @@ 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 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())) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be cleaner if you put more of this logic in the helper method, and also use a helper method for the task/response logic

if (shouldCreateNewTaskAndResponse(...)) {
    taskAndResponse = createNewTaskAndResponse(...) 
   response = taskAndResponse.response
  task = taskAndResponse.task
} else {
   response = findOrCreateResponse(task, enrollee, enrollee.getParticipantUserId(), responseDto, portalId, operator);
}

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 +218,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 @@ -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
23 changes: 19 additions & 4 deletions ui-admin/src/study/surveys/FormOptionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,31 @@ export const FormOptions = ({ studyEnvContext, initialWorkingForm, updateWorking
/> days
</label>
</div>
{workingForm.recurrenceType === 'LONGITUDINAL' && <label className="form-label d-block">
{workingForm.recurrenceType === 'LONGITUDINAL' && <><label className="form-label d-block">
<input type="checkbox" checked={workingForm.prepopulate}
onChange={e => updateWorkingForm({
...workingForm, prepopulate: e.target.checked
})}
/> Prepopulate answers <InfoPopup placement="right" content={<div>
For longitudinal surveys, enabling this will automatically
prepopulate answers from the previous survey response
For longitudinal surveys, enabling this will automatically
prepopulate answers from the previous survey response
</div>}/>
</label> }
</label>
<label className="d-flex align-items-center">
Create a new response after
<TextInput value={workingForm.createNewResponseAfterDays} type="number" min={1} max={9999}
className="mx-2"
style={{ maxWidth: '100px' }}
onChange={val => updateWorkingForm({
...workingForm,
createNewResponseAfterDays: parseInt(val)
})}
/> days <InfoPopup placement="right" content={<div>
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
</div>}/>
</label>
</>}
<h3 className="h6 mt-4">Eligibility Rule</h3>
<div className="mb-2">
<LazySearchQueryBuilder
Expand Down
1 change: 1 addition & 0 deletions ui-admin/src/study/surveys/SurveyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type SaveableFormProps = {
rule?: string
recurrenceType: RecurrenceType
prepopulate: boolean
createNewResponseAfterDays?: number
recurrenceIntervalDays?: number
daysAfterEligible?: number
allowAdminEdit?: boolean
Expand Down
2 changes: 1 addition & 1 deletion ui-core/src/components/forms/PagedSurveyView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ const setupSurveyTest = (survey: Survey, profile?: Profile, referencedAnswers?:
<PagedSurveyView enrollee={enrollee} form={configuredSurvey.survey} response={mockHubResponse().response}
studyEnvParams={{ studyShortcode: 'study', portalShortcode: 'portal', envName: 'sandbox' }}
updateResponseMap={jest.fn()} referencedAnswers={referencedAnswers || []}
selectedLanguage={'en'} updateProfile={jest.fn()} setAutosaveStatus={jest.fn()}
selectedLanguage={'en'} updateProfile={jest.fn()} setAutosaveStatus={jest.fn()} setTaskId={jest.fn()}
taskId={'guid34'} adminUserId={null} updateEnrollee={jest.fn()} onFailure={jest.fn()} onSuccess={jest.fn()}/>
</MockI18nProvider>
</ApiProvider>)
Expand Down
17 changes: 15 additions & 2 deletions ui-core/src/components/forms/PagedSurveyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -40,6 +40,7 @@ export function PagedSurveyView({
updateEnrollee,
updateProfile,
taskId,
setTaskId,
selectedLanguage,
justification,
setAutosaveStatus, enrollee, proxyProfile, adminUserId, onSuccess, onFailure, showHeaders = true
Expand All @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -123,6 +127,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 */
Expand Down
1 change: 1 addition & 0 deletions ui-core/src/types/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type Survey = VersionedForm & {
allowParticipantStart: boolean
allowParticipantReedit: boolean
prepopulate: boolean
createNewResponseAfterDays?: number
eligibilityRule?: string
}

Expand Down
5 changes: 3 additions & 2 deletions ui-participant/src/hub/survey/SurveyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -130,7 +130,8 @@ function SurveyView({ showHeaders = true }: { showHeaders?: boolean }) {
onFailure={onFailure}
updateEnrollee={updateEnrollee}
updateProfile={updateProfile}
taskId={taskId}
taskId={taskId || ''}
setTaskId={setTaskId}
showHeaders={showHeaders}
/>
</ApiProvider>
Expand Down
Loading