From d3ae1153574d30c49c488daa8279e922db87858f Mon Sep 17 00:00:00 2001 From: Matthew Whitfield Date: Thu, 23 May 2024 15:20:42 +0100 Subject: [PATCH] CDPS-716 add no cell prisoners page --- helm_deploy/values-dev.yaml | 1 + helm_deploy/values-preprod.yaml | 1 + helm_deploy/values-prod.yaml | 1 + integration_tests/e2e/noCellAllocated.cy.ts | 58 +++++++++++++ integration_tests/mockApis/prison.ts | 35 ++++++++ integration_tests/mockApis/prisonerSearch.ts | 19 ++++- integration_tests/pages/NoCellAllocated.ts | 9 ++ integration_tests/support/commands.ts | 1 - server/config.ts | 1 + .../establishmentRollController.ts | 19 +++++ server/data/interfaces/bedAssignment.ts | 13 +++ server/data/interfaces/pagedList.ts | 28 ++++++ server/data/interfaces/prisonApiClient.ts | 5 ++ .../data/interfaces/prisonerSearchClient.ts | 2 + server/data/interfaces/userDetail.ts | 14 +++ server/data/prisonApiClient.ts | 19 ++++- server/data/prisonerSearchClient.ts | 30 +++++++ server/routes/establishmentRollRouter.ts | 1 + server/services/movementsService.test.ts | 51 +++++++++++ server/services/movementsService.ts | 53 ++++++++++++ server/test/mocks/offenderCellHistoryMock.ts | 85 +++++++++++++++++++ server/test/mocks/pagedListMock.ts | 14 +++ server/test/mocks/prisonApiClientMock.ts | 2 + .../test/mocks/prisonerSearchApiClientMock.ts | 1 + server/test/mocks/prisonerSearchMock.ts | 3 +- server/test/mocks/userDetailsMock.ts | 26 ++++++ server/utils/dateHelpers.ts | 4 + server/utils/nunjucksSetup.ts | 3 +- server/utils/utils.ts | 12 +++ server/views/pages/noCellAllocated.njk | 75 ++++++++++++++++ 30 files changed, 581 insertions(+), 5 deletions(-) create mode 100644 integration_tests/e2e/noCellAllocated.cy.ts create mode 100644 integration_tests/pages/NoCellAllocated.ts create mode 100644 server/data/interfaces/bedAssignment.ts create mode 100644 server/data/interfaces/pagedList.ts create mode 100644 server/data/interfaces/userDetail.ts create mode 100644 server/test/mocks/offenderCellHistoryMock.ts create mode 100644 server/test/mocks/pagedListMock.ts create mode 100644 server/test/mocks/userDetailsMock.ts create mode 100644 server/views/pages/noCellAllocated.njk diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml index 397203d..f85955a 100644 --- a/helm_deploy/values-dev.yaml +++ b/helm_deploy/values-dev.yaml @@ -69,6 +69,7 @@ generic-service: WELCOME_PEOPLE_INTO_PRISON_URL: https://welcome-dev.prison.service.justice.gov.uk ACCREDITED_PROGRAMMES_URL: https://accredited-programmes-dev.hmpps.service.justice.gov.uk PRISONER_PROFILE_URL: https://prisoner-dev.digital.prison.service.justice.gov.uk + CHANGE_SOMEONES_CELL_URL: https://change-someones-cell-dev.prison.service.justice.gov.uk # Feature flags diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml index 7da2b76..ff2d1ec 100644 --- a/helm_deploy/values-preprod.yaml +++ b/helm_deploy/values-preprod.yaml @@ -68,6 +68,7 @@ generic-service: WELCOME_PEOPLE_INTO_PRISON_URL: https://welcome-preprod.prison.service.justice.gov.uk ACCREDITED_PROGRAMMES_URL: https://accredited-programmes-preprod.hmpps.service.justice.gov.uk PRISONER_PROFILE_URL: https://prisoner-preprod.digital.prison.service.justice.gov.uk + CHANGE_SOMEONES_CELL_URL: https://change-someones-cell-preprod.prison.service.justice.gov.uk # Feature flags USE_OF_FORCE_PRISONS: "ACI,AGI,ASI,AYI,BAI,BCI,BFI,BHI,BLI,BMI,BNI,BRI,BSI,BWI,BXI,BZI,CDI,CFI,CLI,CWI,DAI,DGI,DHI,DMI,DNI,DTI,DWI,EEI,EHI,ESI,EWI,EXI,EYI,FBI,FDI,FEI,FHI,FKI,FMI,FNI,FSI,FWI,GHI,GMI,GNI,GTI,HBI,HCI,HDI,HEI,HHI,HII,HLI,HMI,HOI,HPI,HVI,ISI,IWI,KMI,KVI,LCI,LEI,LFI,LGI,LHI,LII,LLI,LNI,LPI,LTI,LWI,LYI,MDI,MHI,MRI,MSI,MTI,NHI,NLI,NMI,NSI,NWI,ONI,OWI,PBI,PDI,PFI,PNI,PRI,PVI,RCI,RHI,RNI,RSI,SDI,SFI,SHI,SKI,SLI,SNI,SPI,STI,SUI,SWI,TCI,TSI,UKI,UPI,VEI,WCI,WDI,WEI,WHI,WII,WLI,WMI,WRI,WSI,WTI,WWI" diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml index efee275..c401862 100644 --- a/helm_deploy/values-prod.yaml +++ b/helm_deploy/values-prod.yaml @@ -72,6 +72,7 @@ generic-service: WELCOME_PEOPLE_INTO_PRISON_URL: https://welcome.prison.service.justice.gov.uk ACCREDITED_PROGRAMMES_URL: https://accredited-programmes.hmpps.service.justice.gov.uk PRISONER_PROFILE_URL: https://prisoner.digital.prison.service.justice.gov.uk + CHANGE_SOMEONES_CELL_URL: https://change-someones-cell.prison.service.justice.gov.uk # Feature flags ACTIVITIES_ENABLED_PRISONS: "RSI,LPI" diff --git a/integration_tests/e2e/noCellAllocated.cy.ts b/integration_tests/e2e/noCellAllocated.cy.ts new file mode 100644 index 0000000..c4ae0e2 --- /dev/null +++ b/integration_tests/e2e/noCellAllocated.cy.ts @@ -0,0 +1,58 @@ +import Page from '../pages/page' +import { Role } from '../../server/enums/role' +import NoCellAllocatedPage from '../pages/NoCellAllocated' +import { prisonerSearchMock } from '../../server/test/mocks/prisonerSearchMock' + +function visitPageWithRoles(roles: string[]) { + cy.setupUserAuth({ + roles, + caseLoads: [ + { caseloadFunction: '', caseLoadId: 'MDI', currentlyActive: true, description: 'Leeds (HMP)', type: '' }, + ], + }) + cy.signIn({ redirectPath: '/establishment-roll/no-cell-allocated' }) + cy.visit('/establishment-roll/no-cell-allocated') +} +context('In reception Page', () => { + beforeEach(() => { + cy.task('reset') + cy.task('stubPostAttributeSearch') + cy.task('stubGetOffenderCellHistory', prisonerSearchMock[0].bookingId) + cy.task('stubGetOffenderCellHistory', prisonerSearchMock[1].bookingId) + cy.task('getUserDetailsList') + }) + + it('Page is visible', () => { + visitPageWithRoles([`ROLE_PRISON`, `ROLE_${Role.GlobalSearch}`]) + + Page.verifyOnPage(NoCellAllocatedPage) + }) + + it('should display a table row for each wing level assignedRollCount', () => { + visitPageWithRoles([`ROLE_PRISON`, `ROLE_${Role.GlobalSearch}`]) + + const page = Page.verifyOnPage(NoCellAllocatedPage) + page.inReceptionRows().should('have.length', 2) + + page.inReceptionRows().first().find('td').eq(1).should('contain.text', 'Shannon, Eddie') + page.inReceptionRows().first().find('td').eq(2).should('contain.text', 'A1234AB') + page.inReceptionRows().first().find('td').eq(3).should('contain.text', '1-1-2') + page.inReceptionRows().first().find('td').eq(4).should('contain.text', '00:00') + page.inReceptionRows().first().find('td').eq(5).should('contain.text', 'Edwin Shannon') + }) + + it('should display allocation link if user has cell move', () => { + visitPageWithRoles([`ROLE_PRISON`, `ROLE_${Role.GlobalSearch}`, `ROLE_${Role.CellMove}`]) + + const page = Page.verifyOnPage(NoCellAllocatedPage) + page.inReceptionRows().should('have.length', 2) + + page + .inReceptionRows() + .first() + .find('td') + .eq(6) + .find('a[href="http://localhost:3002/prisoner/A1234AB/cell-move/search-for-cell"]') + .should('contain.text', 'Allocate cell') + }) +}) diff --git a/integration_tests/mockApis/prison.ts b/integration_tests/mockApis/prison.ts index 633731b..fb288c9 100644 --- a/integration_tests/mockApis/prison.ts +++ b/integration_tests/mockApis/prison.ts @@ -10,6 +10,9 @@ import { movementsOutMock } from '../../server/test/mocks/movementsOutMock' import { movementsEnRouteMock } from '../../server/test/mocks/movementsEnRouteMock' import { movementsInReceptionMock } from '../../server/test/mocks/movementsInReceptionMock' import { movementsRecentMock } from '../../server/test/mocks/movementsRecentMock' +import { offenderCellHistoryMock } from '../../server/test/mocks/offenderCellHistoryMock' +import { userDetailsMock } from '../../server/test/mocks/userDetailsMock' +import { pagedListMock } from '../../server/test/mocks/pagedListMock' export default { stubUserCaseLoads: (caseLoads: CaseLoad[] = []) => { @@ -269,4 +272,36 @@ export default { }, }) }, + + stubGetOffenderCellHistory: (bookingId = 123) => { + return stubFor({ + request: { + method: 'GET', + url: `/prison/api/bookings/${bookingId}/cell-history?page=0&size=2000`, + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: pagedListMock(offenderCellHistoryMock), + }, + }) + }, + + getUserDetailsList: () => { + return stubFor({ + request: { + method: 'POST', + url: '/prison/api/users/list', + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: userDetailsMock, + }, + }) + }, } diff --git a/integration_tests/mockApis/prisonerSearch.ts b/integration_tests/mockApis/prisonerSearch.ts index 8846cf8..ee688b7 100644 --- a/integration_tests/mockApis/prisonerSearch.ts +++ b/integration_tests/mockApis/prisonerSearch.ts @@ -1,12 +1,13 @@ import { stubFor } from './wiremock' import { prisonerSearchMock } from '../../server/test/mocks/prisonerSearchMock' +import { pagedListMock } from '../../server/test/mocks/pagedListMock' export default { stubPostSearchPrisonersById: () => { return stubFor({ request: { method: 'POST', - urlPattern: '/prisoner-search/prisoner-search/prisoner-numbers', + url: '/prisoner-search/prisoner-search/prisoner-numbers', }, response: { status: 200, @@ -17,4 +18,20 @@ export default { }, }) }, + + stubPostAttributeSearch: () => { + return stubFor({ + request: { + method: 'POST', + url: '/prisoner-search/attribute-search?size=2000', + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: pagedListMock(prisonerSearchMock), + }, + }) + }, } diff --git a/integration_tests/pages/NoCellAllocated.ts b/integration_tests/pages/NoCellAllocated.ts new file mode 100644 index 0000000..c608321 --- /dev/null +++ b/integration_tests/pages/NoCellAllocated.ts @@ -0,0 +1,9 @@ +import Page, { PageElement } from './page' + +export default class NoCellAllocatedPage extends Page { + constructor() { + super('No cell allocated') + } + + inReceptionRows = (): PageElement => cy.get('table.unallocated-roll__table tbody tr') +} diff --git a/integration_tests/support/commands.ts b/integration_tests/support/commands.ts index 14285fd..828f106 100644 --- a/integration_tests/support/commands.ts +++ b/integration_tests/support/commands.ts @@ -22,6 +22,5 @@ Cypress.Commands.add( cy.task('stubSignIn', options) cy.task('stubUserCaseLoads', options.caseLoads) cy.task('stubUserLocations', options.locations) - cy.task('stubGetStaffRoles') }, ) diff --git a/server/config.ts b/server/config.ts index b50d6cd..7f072c8 100755 --- a/server/config.ts +++ b/server/config.ts @@ -235,6 +235,7 @@ export default { serviceUrls: { digitalPrisons: get('DIGITAL_PRISONS_URL', 'http://localhost:3001', requiredInProduction), prisonerProfile: get('PRISONER_PROFILE_URL', 'http://localhost:3002', requiredInProduction), + changeSomeonesCell: get('CHANGE_SOMEONES_CELL_URL', 'http://localhost:3002', requiredInProduction), }, domain: get('INGRESS_URL', 'http://localhost:3000', requiredInProduction), todayCacheTTL: Number(get('TODAY_CACHE_TTL', 0, requiredInProduction)), diff --git a/server/controllers/establishmentRollController.ts b/server/controllers/establishmentRollController.ts index 396f26e..d144646 100644 --- a/server/controllers/establishmentRollController.ts +++ b/server/controllers/establishmentRollController.ts @@ -1,6 +1,8 @@ import { Request, RequestHandler, Response } from 'express' import EstablishmentRollService from '../services/establishmentRollService' import MovementsService from '../services/movementsService' +import { userHasRoles } from '../utils/utils' +import { Role } from '../enums/role' export default class EstablishmentRollController { constructor( @@ -82,4 +84,21 @@ export default class EstablishmentRollController { res.render('pages/inReception', { prisoners: prisonersEnRoute, prison: user.activeCaseLoad.description }) } } + + public getUnallocated(): RequestHandler { + return async (req: Request, res: Response) => { + const { user } = res.locals + const { clientToken } = req.middleware + + const unallocatedPrisoners = await this.movementsService.getNoCellAllocatedPrisoners( + clientToken, + user.activeCaseLoadId, + ) + + res.render('pages/noCellAllocated', { + prisoners: unallocatedPrisoners, + userCanAllocateCell: userHasRoles([Role.CellMove], user.userRoles), + }) + } + } } diff --git a/server/data/interfaces/bedAssignment.ts b/server/data/interfaces/bedAssignment.ts new file mode 100644 index 0000000..f83c426 --- /dev/null +++ b/server/data/interfaces/bedAssignment.ts @@ -0,0 +1,13 @@ +export interface BedAssignment { + bookingId: number + livingUnitId: number + assignmentDate: string + assignmentReason: string + assignmentEndDate: string + assignmentEndDateTime: string + agencyId: string + description: string + bedAssignmentHistorySequence: number + movementMadeBy: string + offenderNo: string +} diff --git a/server/data/interfaces/pagedList.ts b/server/data/interfaces/pagedList.ts new file mode 100644 index 0000000..4d9c872 --- /dev/null +++ b/server/data/interfaces/pagedList.ts @@ -0,0 +1,28 @@ +export interface PagedList { + content: T[] + pageable?: { + sort: { + empty: boolean + sorted: boolean + unsorted: boolean + } + offset: number + pageSize: number + pageNumber: number + paged: boolean + unpaged: boolean + } + totalPages: number + last: boolean + totalElements: number + size: number + number: number + sort: { + empty: boolean + sorted: boolean + unsorted: boolean + } + first: boolean + numberOfElements: number + empty: boolean +} diff --git a/server/data/interfaces/prisonApiClient.ts b/server/data/interfaces/prisonApiClient.ts index f24103f..a061109 100644 --- a/server/data/interfaces/prisonApiClient.ts +++ b/server/data/interfaces/prisonApiClient.ts @@ -9,6 +9,9 @@ import { OffenderIn } from './offenderIn' import { OffenderOut } from './offenderOut' import { OffenderMovement } from './offenderMovement' import { OffenderInReception } from './offenderInReception' +import { PagedList } from './pagedList' +import { BedAssignment } from './bedAssignment' +import { UserDetail } from './userDetail' export interface PrisonApiClient { getUserCaseLoads(): Promise @@ -30,4 +33,6 @@ export interface PrisonApiClient { getStaffRoles(staffId: number, agencyId: string): Promise setActiveCaseload(caseLoad: CaseLoad): Promise> getPrisonerImage(offenderNumber: string, fullSizeImage: boolean): Promise + getOffenderCellHistory(bookingId: number, params?: { page: number; size: number }): Promise> + getUserDetailsList(usernames: string[]): Promise } diff --git a/server/data/interfaces/prisonerSearchClient.ts b/server/data/interfaces/prisonerSearchClient.ts index b836d7d..3705d42 100644 --- a/server/data/interfaces/prisonerSearchClient.ts +++ b/server/data/interfaces/prisonerSearchClient.ts @@ -1,5 +1,7 @@ import { Prisoner } from './prisoner' +import { PagedList } from './pagedList' export interface PrisonerSearchClient { getPrisonersById(prisonerNumbers: string[]): Promise + getCswapPrisonersInEstablishment(prisonId: string): Promise> } diff --git a/server/data/interfaces/userDetail.ts b/server/data/interfaces/userDetail.ts new file mode 100644 index 0000000..02365d3 --- /dev/null +++ b/server/data/interfaces/userDetail.ts @@ -0,0 +1,14 @@ +export interface UserDetail { + staffId: number + username: string + firstName: string + lastName: string + thumbnailId?: number + activeCaseLoadId?: string + accountStatus: 'ACTIVE' | 'INACT' | 'SUS' | 'CAREER' | 'MAT' | 'SAB' | 'SICK' + lockDate: string + expiryDate?: string + lockedFlag?: boolean + expiredFlag?: boolean + active: boolean +} diff --git a/server/data/prisonApiClient.ts b/server/data/prisonApiClient.ts index 89b8315..de2cd57 100644 --- a/server/data/prisonApiClient.ts +++ b/server/data/prisonApiClient.ts @@ -12,6 +12,9 @@ import { OffenderIn } from './interfaces/offenderIn' import { OffenderOut } from './interfaces/offenderOut' import { OffenderMovement } from './interfaces/offenderMovement' import { OffenderInReception } from './interfaces/offenderInReception' +import { UserDetail } from './interfaces/userDetail' +import { BedAssignment } from './interfaces/bedAssignment' +import { PagedList } from './interfaces/pagedList' export default class PrisonApiRestClient implements PrisonApiClient { constructor(private restClient: RestClient) {} @@ -102,9 +105,23 @@ export default class PrisonApiRestClient implements PrisonApiClient { return this.put>({ path: '/api/users/me/activeCaseLoad', data: caseLoad }) } - async getPrisonerImage(prisonerNumber: string, fullSizeImage: boolean): Promise { + getPrisonerImage(prisonerNumber: string, fullSizeImage: boolean): Promise { return this.restClient.stream({ path: `/api/bookings/offenderNo/${prisonerNumber}/image/data?fullSizeImage=${fullSizeImage}`, }) } + + getOffenderCellHistory( + bookingId: number, + pagedParams: { page: number; size: number } = { page: 0, size: 2000 }, + ): Promise> { + return this.get>({ + path: `/api/bookings/${bookingId}/cell-history`, + query: querystring.stringify(pagedParams), + }) + } + + getUserDetailsList(usernames: string[]): Promise { + return this.post({ path: '/api/users/list', data: usernames }) + } } diff --git a/server/data/prisonerSearchClient.ts b/server/data/prisonerSearchClient.ts index f2ada65..1c169d4 100644 --- a/server/data/prisonerSearchClient.ts +++ b/server/data/prisonerSearchClient.ts @@ -1,6 +1,7 @@ import RestClient from './restClient' import { Prisoner } from './interfaces/prisoner' import { PrisonerSearchClient } from './interfaces/prisonerSearchClient' +import { PagedList } from './interfaces/pagedList' export default class PrisonerSearchRestClient implements PrisonerSearchClient { constructor(private restClient: RestClient) {} @@ -11,4 +12,33 @@ export default class PrisonerSearchRestClient implements PrisonerSearchClient { data: { prisonerNumbers }, }) } + + async getCswapPrisonersInEstablishment(prisonId: string): Promise> { + const attributeRequest = { + joinType: 'AND', + queries: [ + { + joinType: 'AND', + matchers: [ + { + type: 'String', + attribute: 'prisonId', + condition: 'IS', + searchTerm: prisonId, + }, + { + type: 'String', + attribute: 'cellLocation', + condition: 'IN', + searchTerm: 'CSWAP', + }, + ], + }, + ], + } + return this.restClient.post>({ + path: '/attribute-search?size=2000', + data: attributeRequest, + }) + } } diff --git a/server/routes/establishmentRollRouter.ts b/server/routes/establishmentRollRouter.ts index 52fcd78..fd57484 100644 --- a/server/routes/establishmentRollRouter.ts +++ b/server/routes/establishmentRollRouter.ts @@ -31,6 +31,7 @@ export default function establishmentRollRouter(services: Services): Router { get('/out-today', establishmentRollController.getOutToday()) get('/en-route', establishmentRollController.getEnRoute()) get('/in-reception', establishmentRollController.getInReception()) + get('/no-cell-allocated', establishmentRollController.getUnallocated()) return router } diff --git a/server/services/movementsService.test.ts b/server/services/movementsService.test.ts index 3bc762d..918c969 100644 --- a/server/services/movementsService.test.ts +++ b/server/services/movementsService.test.ts @@ -7,6 +7,9 @@ import { movementsOutMock } from '../test/mocks/movementsOutMock' import { movementsEnRouteMock } from '../test/mocks/movementsEnRouteMock' import { movementsInReceptionMock } from '../test/mocks/movementsInReceptionMock' import { movementsRecentMock } from '../test/mocks/movementsRecentMock' +import { offenderCellHistory2Mock, offenderCellHistoryMock } from '../test/mocks/offenderCellHistoryMock' +import { userDetailsMock } from '../test/mocks/userDetailsMock' +import { pagedListMock } from '../test/mocks/pagedListMock' describe('movementsService', () => { let movementsService: MovementsService @@ -193,4 +196,52 @@ describe('movementsService', () => { expect(result).toEqual([]) }) }) + + describe('getNoCellAllocatedPrisoners', () => { + it('should search for prisoners with CSWAP living unit and embellish with movements', async () => { + prisonerSearchApiClientMock.getCswapPrisonersInEstablishment = jest + .fn() + .mockResolvedValue(pagedListMock(prisonerSearchMock)) + + prisonApiClientMock.getOffenderCellHistory = jest + .fn() + .mockResolvedValueOnce(pagedListMock(offenderCellHistoryMock)) + .mockResolvedValueOnce(pagedListMock(offenderCellHistory2Mock)) + prisonApiClientMock.getUserDetailsList = jest.fn().mockResolvedValue(userDetailsMock) + + const result = await movementsService.getNoCellAllocatedPrisoners('token', 'MDI') + expect(prisonApiClientMock.getOffenderCellHistory).toHaveBeenCalledTimes(2) + expect(prisonApiClientMock.getOffenderCellHistory).toHaveBeenCalledWith(123) + expect(prisonApiClientMock.getOffenderCellHistory).toHaveBeenCalledWith(456) + expect(prisonApiClientMock.getUserDetailsList).toHaveBeenCalledWith(['ESHANNON', 'CWADDLE']) + + expect(result).toEqual([ + { + ...prisonerSearchMock[0], + movedBy: 'Edwin Shannon', + previousCell: '1-1-2', + timeOut: '2021-01-01T00:00:00', + }, + { + ...prisonerSearchMock[1], + movedBy: 'Chris Waddle', + previousCell: '2-1-3', + timeOut: '2021-01-01T00:00:00', + }, + ]) + }) + + it('should return empty api if no CSWAP prisoners', async () => { + prisonerSearchApiClientMock.getCswapPrisonersInEstablishment = jest.fn().mockResolvedValue({ content: [] }) + + prisonApiClientMock.getOffenderCellHistory = jest.fn().mockResolvedValue(offenderCellHistoryMock) + prisonApiClientMock.getUserDetailsList = jest.fn().mockResolvedValue(userDetailsMock) + + const result = await movementsService.getNoCellAllocatedPrisoners('token', 'LEI') + expect(prisonApiClientMock.getOffenderCellHistory).toBeCalledTimes(0) + expect(prisonApiClientMock.getUserDetailsList).toBeCalledTimes(0) + + expect(result).toEqual([]) + }) + }) }) diff --git a/server/services/movementsService.ts b/server/services/movementsService.ts index 95db56d..6467de7 100644 --- a/server/services/movementsService.ts +++ b/server/services/movementsService.ts @@ -3,6 +3,9 @@ import { PrisonApiClient } from '../data/interfaces/prisonApiClient' import { PrisonerSearchClient } from '../data/interfaces/prisonerSearchClient' import { mapAlerts } from './utils/alertFlagLabels' import { PrisonerWithAlerts } from './interfaces/establishmentRollService/PrisonerWithAlerts' +import { stripAgencyPrefix } from '../utils/utils' +import { Prisoner } from '../data/interfaces/prisoner' +import { BedAssignment } from '../data/interfaces/bedAssignment' export default class MovementsService { constructor( @@ -116,4 +119,54 @@ export default class MovementsService { } }) } + + public async getNoCellAllocatedPrisoners( + clientToken: string, + caseLoadId: string, + ): Promise<(Prisoner & { movedBy: string; previousCell: string; timeOut: string })[]> { + const prisonApi = this.prisonApiClientBuilder(clientToken) + const prisonerSearchClient = this.prisonerSearchClientBuilder(clientToken) + + const { content: cellSwapPrisoners } = await prisonerSearchClient.getCswapPrisonersInEstablishment(caseLoadId) + if (!cellSwapPrisoners?.length) return [] + + const prisonersWithLocations: { + prisoner: Prisoner + currentLocation: BedAssignment + previousLocation: BedAssignment + }[] = await Promise.all( + cellSwapPrisoners.map(async prisoner => { + const { content: cellHistory } = await prisonApi.getOffenderCellHistory(prisoner.bookingId) + + const cellHistoryDescendingSequence = cellHistory.sort( + (left, right) => right.bedAssignmentHistorySequence - left.bedAssignmentHistorySequence, + ) + const currentLocation = cellHistoryDescendingSequence[0] + const previousLocation = cellHistoryDescendingSequence[1] + + return { + prisoner, + currentLocation, + previousLocation, + } + }), + ) + + const allStaffUsernames = prisonersWithLocations.map(prisoner => prisoner.currentLocation.movementMadeBy) + const allStaffDetails = allStaffUsernames.length + ? await prisonApi.getUserDetailsList([...new Set(allStaffUsernames)]) + : [] + + return prisonersWithLocations.map(prisonerWithLocations => { + const { currentLocation, previousLocation, prisoner } = prisonerWithLocations + const movementMadeBy = allStaffDetails.find(staffUser => staffUser.username === currentLocation.movementMadeBy) + + return { + ...prisoner, + movedBy: movementMadeBy ? `${movementMadeBy.firstName} ${movementMadeBy.lastName}` : '', + previousCell: stripAgencyPrefix(previousLocation.description, caseLoadId), + timeOut: previousLocation.assignmentEndDateTime, + } + }) + } } diff --git a/server/test/mocks/offenderCellHistoryMock.ts b/server/test/mocks/offenderCellHistoryMock.ts new file mode 100644 index 0000000..406b0be --- /dev/null +++ b/server/test/mocks/offenderCellHistoryMock.ts @@ -0,0 +1,85 @@ +import { BedAssignment } from '../../data/interfaces/bedAssignment' + +export const offenderCellHistoryMock: BedAssignment[] = [ + { + bookingId: 1, + livingUnitId: 1, + assignmentDate: '2021-01-01', + assignmentReason: 'ADM', + assignmentEndDate: '2021-01-01', + assignmentEndDateTime: '2021-01-01T00:00:00', + agencyId: 'MDI', + description: 'MDI-1-1-1', + bedAssignmentHistorySequence: 1, + movementMadeBy: 'CWADDLE', + offenderNo: 'A1234AB', + }, + { + bookingId: 1, + livingUnitId: 1, + assignmentDate: '2021-01-01', + assignmentReason: 'ADM', + assignmentEndDate: '2021-01-01', + assignmentEndDateTime: '2021-01-01T00:00:00', + agencyId: 'MDI', + description: 'MDI-1-1-2', + bedAssignmentHistorySequence: 2, + movementMadeBy: 'MWHITFIELD', + offenderNo: 'A1234AB', + }, + { + bookingId: 1, + livingUnitId: 1, + assignmentDate: '2021-01-01', + assignmentReason: 'ADM', + assignmentEndDate: '2021-01-01', + assignmentEndDateTime: '2021-01-01T00:00:00', + agencyId: 'MDI', + description: 'MDI-1-1-3', + bedAssignmentHistorySequence: 3, + movementMadeBy: 'ESHANNON', + offenderNo: 'A1234AB', + }, +] + +export const offenderCellHistory2Mock: BedAssignment[] = [ + { + bookingId: 4, + livingUnitId: 4, + assignmentDate: '2021-01-01', + assignmentReason: 'ADM', + assignmentEndDate: '2021-01-01', + assignmentEndDateTime: '2021-01-01T00:00:00', + agencyId: 'MDI', + description: 'MDI-2-1-1', + bedAssignmentHistorySequence: 3, + movementMadeBy: 'CWADDLE', + offenderNo: 'A1234AB', + }, + { + bookingId: 5, + livingUnitId: 5, + assignmentDate: '2021-01-01', + assignmentReason: 'ADM', + assignmentEndDate: '2021-01-01', + assignmentEndDateTime: '2021-01-01T00:00:00', + agencyId: 'MDI', + description: 'MDI-2-1-2', + bedAssignmentHistorySequence: 1, + movementMadeBy: 'MWHITFIELD', + offenderNo: 'A1234AB', + }, + { + bookingId: 6, + livingUnitId: 6, + assignmentDate: '2021-01-01', + assignmentReason: 'ADM', + assignmentEndDate: '2021-01-01', + assignmentEndDateTime: '2021-01-01T00:00:00', + agencyId: 'MDI', + description: 'MDI-2-1-3', + bedAssignmentHistorySequence: 2, + movementMadeBy: 'ESHANNON', + offenderNo: 'A1234AB', + }, +] diff --git a/server/test/mocks/pagedListMock.ts b/server/test/mocks/pagedListMock.ts new file mode 100644 index 0000000..dffe3eb --- /dev/null +++ b/server/test/mocks/pagedListMock.ts @@ -0,0 +1,14 @@ +import { PagedList } from '../../data/interfaces/pagedList' + +export const pagedListMock = (content: T[]): PagedList => ({ + empty: false, + first: false, + last: false, + number: 0, + numberOfElements: 0, + size: 0, + sort: { empty: false, sorted: false, unsorted: false }, + totalElements: 0, + totalPages: 0, + content, +}) diff --git a/server/test/mocks/prisonApiClientMock.ts b/server/test/mocks/prisonApiClientMock.ts index 4e3179e..57b1293 100644 --- a/server/test/mocks/prisonApiClientMock.ts +++ b/server/test/mocks/prisonApiClientMock.ts @@ -17,6 +17,8 @@ const prisonApiClientMock: PrisonApiClient = { getMovementsEnRoute: jest.fn(), getRecentMovements: jest.fn(), getMovementsInReception: jest.fn(), + getOffenderCellHistory: jest.fn(), + getUserDetailsList: jest.fn(), } export default prisonApiClientMock diff --git a/server/test/mocks/prisonerSearchApiClientMock.ts b/server/test/mocks/prisonerSearchApiClientMock.ts index 9a42da4..c5d8120 100644 --- a/server/test/mocks/prisonerSearchApiClientMock.ts +++ b/server/test/mocks/prisonerSearchApiClientMock.ts @@ -2,6 +2,7 @@ import { PrisonerSearchClient } from '../../data/interfaces/prisonerSearchClient const prisonerSearchApiClientMock: PrisonerSearchClient = { getPrisonersById: jest.fn(), + getCswapPrisonersInEstablishment: jest.fn(), } export default prisonerSearchApiClientMock diff --git a/server/test/mocks/prisonerSearchMock.ts b/server/test/mocks/prisonerSearchMock.ts index 558e62f..23ef787 100644 --- a/server/test/mocks/prisonerSearchMock.ts +++ b/server/test/mocks/prisonerSearchMock.ts @@ -1,9 +1,9 @@ import { Prisoner } from '../../data/interfaces/prisoner' -// eslint-disable-next-line import/prefer-default-export export const prisonerSearchMock: Prisoner[] = [ { prisonerNumber: 'A1234AA', + bookingId: 123, firstName: 'John', lastName: 'Smith', dateOfBirth: '1980-01-01', @@ -29,6 +29,7 @@ export const prisonerSearchMock: Prisoner[] = [ }, { prisonerNumber: 'A1234AB', + bookingId: 456, firstName: 'Eddie', lastName: 'Shannon', dateOfBirth: '1980-01-01', diff --git a/server/test/mocks/userDetailsMock.ts b/server/test/mocks/userDetailsMock.ts new file mode 100644 index 0000000..9511994 --- /dev/null +++ b/server/test/mocks/userDetailsMock.ts @@ -0,0 +1,26 @@ +import { UserDetail } from '../../data/interfaces/userDetail' + +export const userDetailsMock: UserDetail[] = [ + { + staffId: 1, + username: 'ESHANNON', + firstName: 'Edwin', + lastName: 'Shannon', + thumbnailId: 1, + activeCaseLoadId: 'MDI', + accountStatus: 'ACTIVE', + lockDate: '', + active: true, + }, + { + staffId: 2, + username: 'CWADDLE', + firstName: 'Chris', + lastName: 'Waddle', + thumbnailId: 2, + activeCaseLoadId: 'MDI', + accountStatus: 'ACTIVE', + lockDate: '', + active: true, + }, +] diff --git a/server/utils/dateHelpers.ts b/server/utils/dateHelpers.ts index 7c87535..66537bd 100644 --- a/server/utils/dateHelpers.ts +++ b/server/utils/dateHelpers.ts @@ -132,3 +132,7 @@ export const toUnixTimeStamp = (isoDate: string, time: string): number => { const fullDate = time ? `${isoDate}T${time}` : isoDate return new Date(fullDate).getTime() } + +export const timeFromDate = (isoString: string): string => { + return formatTime(isoString.split('T')[1].split('.')[0]) +} diff --git a/server/utils/nunjucksSetup.ts b/server/utils/nunjucksSetup.ts index 8529b29..514eafa 100644 --- a/server/utils/nunjucksSetup.ts +++ b/server/utils/nunjucksSetup.ts @@ -13,7 +13,7 @@ import { userHasRoles, } from './utils' import { ApplicationInfo } from '../applicationInfo' -import { formatDate, formatDateTime, formatTime, toUnixTimeStamp } from './dateHelpers' +import { formatDate, formatDateTime, formatTime, timeFromDate, toUnixTimeStamp } from './dateHelpers' import config from '../config' const production = process.env.NODE_ENV === 'production' @@ -69,4 +69,5 @@ export default function nunjucksSetup(app: express.Express, applicationInfo: App njkEnv.addFilter('formatTime', formatTime) njkEnv.addFilter('formatName', formatName) njkEnv.addFilter('toUnixTimeStamp', toUnixTimeStamp) + njkEnv.addFilter('timeFromDate', timeFromDate) } diff --git a/server/utils/utils.ts b/server/utils/utils.ts index 82c18a5..e2ee05d 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -135,3 +135,15 @@ export const formatName = ( .join(' ') .replace(/(^\w)|([\s'-]+\w)/g, letter => letter.toUpperCase()) } + +export const stripAgencyPrefix = (location: string, agency: string): string => { + const parts = location && location.split('-') + if (parts && parts.length > 0) { + const index = parts.findIndex(p => p === agency) + if (index >= 0) { + return location.substring(parts[index].length + 1, location.length) + } + } + + return null +} diff --git a/server/views/pages/noCellAllocated.njk b/server/views/pages/noCellAllocated.njk new file mode 100644 index 0000000..831372e --- /dev/null +++ b/server/views/pages/noCellAllocated.njk @@ -0,0 +1,75 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/alertFlags.njk" import alertFlags %} +{% from "../macros/categoryFlag.njk" import categoryFlag %} + +{% set pageTitle = "No cell allocated"%} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: '/' + }, + { + text: 'Establishment roll', + href: '/establishment-roll' + } +] %} + +{% block content %} +
+ {% if prisoners.length %} +
+
+

{{ pageTitle }}

+

These people have been moved out of their cell to create a space for someone else and do not currently have a cell allocated.

+
+
+ +
+
+ + + + + + + + + + {% if userCanAllocateCell %} + + {% endif %} + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + {% if userCanAllocateCell %} + + {% endif %} + + {% endfor %} + +
PictureNamePrisoner numberPrevious cellTime moved outMoved out byAllocate
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.previousCell }}{{ prisoner.timeOut | timeFromDate }}{{ prisoner.movedBy }} + Allocate cell +
+
+
+ {% else %} +
+
+

{{ pageTitle }}

+

There are no prisoners without a cell.

+
+
+ {% endif %} +
+{% endblock %}