Skip to content

Commit

Permalink
[JN-1164] surfacing withdrawn enrollee data (#1005)
Browse files Browse the repository at this point in the history
Co-authored-by: Connor Barker <connorlbark@gmail.com>
Co-authored-by: Matt Bemis <MatthewBemis@users.noreply.github.com>
  • Loading branch information
3 people authored Jul 23, 2024
1 parent 3db6627 commit 307420f
Show file tree
Hide file tree
Showing 15 changed files with 282 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package bio.terra.pearl.api.admin.controller.enrollee;

import bio.terra.pearl.api.admin.api.WithdrawnEnrolleeApi;
import bio.terra.pearl.api.admin.service.auth.AuthUtilService;
import bio.terra.pearl.api.admin.service.auth.context.PortalStudyEnvAuthContext;
import bio.terra.pearl.api.admin.service.enrollee.WithdrawnEnrolleeExtService;
import bio.terra.pearl.core.model.EnvironmentName;
import bio.terra.pearl.core.model.admin.AdminUser;
import bio.terra.pearl.core.model.participant.WithdrawnEnrollee;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;

@Controller
public class WithdrawnEnrolleeController implements WithdrawnEnrolleeApi {

private final AuthUtilService authUtilService;
private final HttpServletRequest request;
private final WithdrawnEnrolleeExtService withdrawnEnrolleeExtService;

public WithdrawnEnrolleeController(
AuthUtilService authUtilService,
WithdrawnEnrolleeExtService enrolleeExtService,
HttpServletRequest request) {
this.authUtilService = authUtilService;
this.withdrawnEnrolleeExtService = enrolleeExtService;
this.request = request;
}

@Override
public ResponseEntity<Object> getAll(
String portalShortcode, String studyShortcode, String envName) {
AdminUser operator = authUtilService.requireAdminUser(request);
EnvironmentName environmentName = EnvironmentName.valueOfCaseInsensitive(envName);
List<WithdrawnEnrollee> enrollees =
withdrawnEnrolleeExtService.getAll(
PortalStudyEnvAuthContext.of(
operator, portalShortcode, studyShortcode, environmentName));
return ResponseEntity.ok(enrollees);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package bio.terra.pearl.api.admin.service.enrollee;

import bio.terra.pearl.api.admin.service.auth.EnforcePortalStudyEnvPermission;
import bio.terra.pearl.api.admin.service.auth.context.PortalStudyEnvAuthContext;
import bio.terra.pearl.core.model.participant.WithdrawnEnrollee;
import bio.terra.pearl.core.service.participant.WithdrawnEnrolleeService;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class WithdrawnEnrolleeExtService {
private final WithdrawnEnrolleeService withdrawnEnrolleeService;

public WithdrawnEnrolleeExtService(WithdrawnEnrolleeService withdrawnEnrolleeService) {
this.withdrawnEnrolleeService = withdrawnEnrolleeService;
}

@EnforcePortalStudyEnvPermission(permission = "participant_data_view")
public List<WithdrawnEnrollee> getAll(PortalStudyEnvAuthContext authContext) {
return withdrawnEnrolleeService.findByStudyEnvironmentIdNoData(
authContext.getStudyEnvironment().getId());
}
}
15 changes: 15 additions & 0 deletions api-admin/src/main/resources/api/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,21 @@ paths:
content: { application/json: { schema: { type: object } } }
'500':
$ref: '#/components/responses/ServerError'
/api/portals/v1/{portalShortcode}/studies/{studyShortcode}/env/{envName}/withdrawnEnrollees:
get:
summary: gets list of withdrawn participants (with no data attached)
tags: [ withdrawnEnrollee ]
operationId: getAll
parameters:
- { name: portalShortcode, in: path, required: true, schema: { type: string } }
- { name: studyShortcode, in: path, required: true, schema: { type: string } }
- { name: envName, in: path, required: true, schema: { type: string } }
responses:
'200':
description: withdrawnEnrollee list
content: { application/json: { schema: { type: object } } }
'500':
$ref: '#/components/responses/ServerError'
/api/portals/v1/{portalShortcode}/studies/{studyShortcode}/env/{envName}/enrolleeRelations/byTarget/{enrolleeShortcode}:
get:
summary: Finds all of the relations that the given enrollee is a target of. Includes the full enrollee objects.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package bio.terra.pearl.api.admin.service.enrollee;

import bio.terra.pearl.api.admin.AuthAnnotationSpec;
import bio.terra.pearl.api.admin.AuthTestUtils;
import bio.terra.pearl.api.admin.BaseSpringBootTest;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

public class WithdrawnEnrolleeExtServiceTests extends BaseSpringBootTest {
@Autowired private WithdrawnEnrolleeExtService withdrawnEnrolleeExtService;

@Test
public void testMethodAnnotations() {
AuthTestUtils.assertAllMethodsAnnotated(
withdrawnEnrolleeExtService,
Map.of("getAll", AuthAnnotationSpec.withPortalStudyEnvPerm("participant_data_view")));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import bio.terra.pearl.core.model.participant.Enrollee;
import bio.terra.pearl.core.model.participant.EnrolleeRelation;
import bio.terra.pearl.core.model.participant.WithdrawnEnrollee;
import bio.terra.pearl.core.model.survey.Survey;
import org.jdbi.v3.core.Jdbi;
import org.springframework.stereotype.Component;

Expand Down Expand Up @@ -39,6 +40,18 @@ protected Class<WithdrawnEnrollee> getClazz() {
return WithdrawnEnrollee.class;
}

/** exclude the enrolleeData, as that could be multiple MB of data */
public List<WithdrawnEnrollee> findByStudyEnvironmentIdNoData(UUID studyEnvironmentId) {
return jdbi.withHandle(handle ->
handle.createQuery("""
select id, created_at, last_updated_at, shortcode, user_data from %s where study_environment_id = :studyEnvironmentId;
""".formatted(tableName))
.bind("studyEnvironmentId", studyEnvironmentId)
.mapTo(clazz)
.list()
);
}

public void deleteByStudyEnvironmentId(UUID studyEnvironmentId) {
deleteByProperty("study_environment_id", studyEnvironmentId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import bio.terra.pearl.core.service.workflow.EnrollmentService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.With;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -50,6 +51,11 @@ public void deleteByStudyEnvironmentId(UUID studyEnvironmentId) {
dao.deleteByStudyEnvironmentId(studyEnvironmentId);
}

/** Returns a list of WithdrawnEnrollees for the given study environment, but without the enrollee data. */
public List<WithdrawnEnrollee> findByStudyEnvironmentIdNoData(UUID studyEnvironmentId) {
return dao.findByStudyEnvironmentIdNoData(studyEnvironmentId);
}

public int countByStudyEnvironmentId(UUID studyEnvironmentId) {
return dao.countByStudyEnvironmentId(studyEnvironmentId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;

public class WithdrawnEnrolleeServiceTests extends BaseSpringBootTest {
@Autowired
Expand All @@ -37,7 +38,7 @@ public class WithdrawnEnrolleeServiceTests extends BaseSpringBootTest {

@Test
@Transactional
public void testWithdraw(TestInfo info) throws Exception {
public void testWithdraw(TestInfo info) {
Enrollee enrollee = enrolleeFactory.buildPersisted(getTestName(info));
DaoTestUtils.assertGeneratedProperties(enrollee);
WithdrawnEnrollee withdrawnEnrollee = withdrawnEnrolleeService.withdrawEnrollee(enrollee, getAuditInfo(info));
Expand All @@ -46,6 +47,12 @@ public void testWithdraw(TestInfo info) throws Exception {
assertThat(enrolleeService.find(enrollee.getId()).isPresent(), equalTo(false));
assertThat(withdrawnEnrolleeService.find(withdrawnEnrollee.getId()).isPresent(), equalTo(true));
assertThat(withdrawnEnrolleeService.isWithdrawn(enrollee.getShortcode()), equalTo(true));

// confirm we can fetch by study environment without returning all the data
List<WithdrawnEnrollee> withdrawnEnrollees = withdrawnEnrolleeService.findByStudyEnvironmentIdNoData(enrollee.getStudyEnvironmentId());
assertThat(withdrawnEnrollees.size(), equalTo(1));
assertThat(withdrawnEnrollees.get(0).getShortcode(), equalTo(enrollee.getShortcode()));
assertThat(withdrawnEnrollees.get(0).getEnrolleeData(), nullValue());
}
@Test
@Transactional
Expand Down
12 changes: 12 additions & 0 deletions ui-admin/src/api/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ export type SearchValueTypeDefinition = {
allowOtherDescription: boolean
}

export type WithdrawnEnrollee = {
createdAt: number
shortcode: string
userData: string
}

let bearerToken: string | null = null
export const API_ROOT = '/api'

Expand Down Expand Up @@ -771,6 +777,12 @@ export default {
return await this.processJsonResponse(response)
},

async fetchWithdrawnEnrollees(studyEnvParams: StudyEnvParams): Promise<WithdrawnEnrollee[]> {
const url = `${baseStudyEnvUrlFromParams(studyEnvParams)}/withdrawnEnrollees`
const response = await fetch(url, this.getGetInit())
return await this.processJsonResponse(response)
},

async fetchEnrolleeChangeRecords(portalShortcode: string, studyShortcode: string, envName: string,
enrolleeShortcode: string, modelName?: string): Promise<DataChangeRecord[]> {
const params = queryString.stringify({ modelName })
Expand Down
2 changes: 2 additions & 0 deletions ui-admin/src/study/participants/ParticipantsRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StudyEnvContextT } from '../StudyEnvironmentRouter'
import EnrolleeView from './enrolleeView/EnrolleeView'
import { NavBreadcrumb } from 'navbar/AdminNavbar'
import { StudyEnvParams } from '@juniper/ui-core'
import WithdrawnEnrolleeList from './participantList/WithdrawnEnrolleeList'

/** routes to list or individual enrollee view as appropriate */
export default function ParticipantsRouter({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) {
Expand All @@ -18,6 +19,7 @@ export default function ParticipantsRouter({ studyEnvContext }: {studyEnvContext
participants</Link>
</NavBreadcrumb>
<Routes>
<Route path="withdrawn" element={<WithdrawnEnrolleeList studyEnvContext={studyEnvContext}/>}/>
<Route path=":enrolleeShortcodeOrId/*" element={<EnrolleeView studyEnvContext={studyEnvContext}/>}/>
<Route index element={<ParticipantList studyEnvContext={studyEnvContext}/>}/>
<Route path="*" element={<div>Unknown participant page</div>}/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'
import { doApiLoad } from 'api/api-utils'
import { Button } from 'components/forms/Button'
import { Enrollee } from '@juniper/ui-core'
import { DocsKey, ZendeskLink } from 'util/zendeskUtils'

/** shows not-commonly-used enrollee functionality */
export default function AdvancedOptions({ enrollee, studyEnvContext }:
Expand All @@ -28,10 +29,14 @@ export default function AdvancedOptions({ enrollee, studyEnvContext }:
<form onSubmit={e => e.preventDefault()}>
<h3 className="h5">Withdraw enrollee: {enrollee.profile.givenName} {enrollee.profile.familyName}</h3>
<div>contact email: {enrollee.profile.contactEmail}</div>
<div className="my-3">
<strong>Withdrawal is permanent!</strong> Read more about the
<ZendeskLink doc={DocsKey.WITHDRAWAL}> withdrawal process</ZendeskLink>.
</div>
<div className="my-3">
<label>
Confirm by typing &quot;{withdrawString}&quot; below.<br/>
<strong>Withdrawal is permanent!</strong>

<input type="text" className="form-control" value={shortcodeConfirm}
onChange={e => setShortcodeConfirm(e.target.value)}/>
</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ParticipantListTableGroupedByFamily from 'study/participants/participantL
import ParticipantListTable from 'study/participants/participantList/ParticipantListTable'
import { Button } from 'components/forms/Button'
import { useSingleSearchParam } from 'util/searchParamsUtils'
import { Link } from 'react-router-dom'

/** Shows a list of (for now) enrollees */
function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) {
Expand Down Expand Up @@ -55,7 +56,7 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT

return <div className="ParticipantList container-fluid px-4 py-2">
{renderPageHeader('Participant List')}
<div className="d-flex align-content-center">
<div className="d-flex align-content-center align-items-center">
<ParticipantSearch
key={currentEnv.environmentName}
studyEnvContext={studyEnvContext}
Expand All @@ -75,7 +76,7 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT
</Button>
</div>
}

<div><Link to={`withdrawn`}>Withdrawn</Link></div>
</div>


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react'
import { mockStudyEnvContext, renderInPortalRouter } from 'test-utils/mocking-utils'
import WithdrawnEnrolleeList from './WithdrawnEnrolleeList'
import Api from 'api/api'
import { waitFor, screen } from '@testing-library/react'


test('renders list', async () => {
const studyEnvContext = mockStudyEnvContext()
jest.spyOn(Api, 'fetchWithdrawnEnrollees').mockResolvedValue([
{ shortcode: 'BLEH', userData: '{"username": "foo@bar.com", "createdAt": 0}', createdAt: 123 }
])
renderInPortalRouter(studyEnvContext.portal,
<WithdrawnEnrolleeList studyEnvContext={studyEnvContext} />)
await waitFor(() => expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument())
expect(screen.getByText('BLEH')).toBeInTheDocument()
// email should be hidden by default
expect(screen.queryByText('foo@bar.com')).not.toBeInTheDocument()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { useState } from 'react'
import { paramsFromContext, StudyEnvContextT } from '../../StudyEnvironmentRouter'
import { renderPageHeader } from '../../../util/pageUtils'
import { useLoadingEffect } from '../../../api/api-utils'
import Api, { WithdrawnEnrollee } from '../../../api/api'
import { basicTableLayout, ColumnVisibilityControl } from '../../../util/tableUtils'
import LoadingSpinner from '../../../util/LoadingSpinner'
import {
ColumnDef,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState
} from '@tanstack/react-table'
import { instantToDefaultString } from '@juniper/ui-core'
import { NavBreadcrumb } from '../../../navbar/AdminNavbar'
import { DocsKey, ZendeskLink } from '../../../util/zendeskUtils'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'

type WithdrawnEnrolleeExtract = WithdrawnEnrollee & {
userDataObj: { username: string, createdAt: number }
}

/**
* show a list of withdrawn enrollees with account information
*/
export default function WithdrawnEnrolleeList({ studyEnvContext }: { studyEnvContext: StudyEnvContextT}) {
const [enrollees, setEnrollees] = useState<WithdrawnEnrolleeExtract[]>([])
const [sorting, setSorting] = React.useState<SortingState>([{ 'id': 'createdAt', 'desc': true }])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({
'email': false
})
const { isLoading } = useLoadingEffect(async () => {
const result = await Api.fetchWithdrawnEnrollees(paramsFromContext(studyEnvContext))
const extracts: WithdrawnEnrolleeExtract[] = result.map(enrollee => ({
...enrollee,
userDataObj: JSON.parse(enrollee.userData)
}))
setEnrollees(extracts)
}, [studyEnvContext.currentEnvPath])

const columns: ColumnDef<WithdrawnEnrolleeExtract>[] = [{
header: 'Shortcode',
accessorKey: 'shortcode'
}, {
header: 'Email',
id: 'email',
accessorKey: 'userDataObj.username'
}, {
header: 'Account created',
accessorKey: 'userDataObj.createdAt',
meta: {
columnType: 'instant'
},
cell: info => instantToDefaultString(info.getValue() as number)
}, {
header: 'Withdrawn',
accessorKey: 'createdAt',
meta: {
columnType: 'instant'
},
cell: info => instantToDefaultString(info.getValue() as number)
}]

const table = useReactTable({
data: enrollees,
columns,
state: {
sorting,
columnVisibility
},
onColumnVisibilityChange: setColumnVisibility,
enableRowSelection: true,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
})
return <div className="container-fluid px-4 py-2">
{ renderPageHeader('Withdrawn enrollees') }
<NavBreadcrumb value={'withdrawnList'}>Withdrawn</NavBreadcrumb>
<FontAwesomeIcon icon={faInfoCircle}/> More information about the
<ZendeskLink doc={DocsKey.WITHDRAWAL}> withdrawal process</ZendeskLink>.
<LoadingSpinner isLoading={isLoading}>
<div className="d-flex justify-content-end">
<ColumnVisibilityControl table={table}/>
</div>
<div className="d-flex align-items-center justify-content-between">
{ basicTableLayout(table) }
</div>
</LoadingSpinner>
</div>
}
Loading

0 comments on commit 307420f

Please sign in to comment.