diff --git a/integration_tests/e2e/currentlyOut.cy.ts b/integration_tests/e2e/currentlyOut.cy.ts new file mode 100644 index 0000000..9d2a8e8 --- /dev/null +++ b/integration_tests/e2e/currentlyOut.cy.ts @@ -0,0 +1,46 @@ +import Page from '../pages/page' +import { Role } from '../../server/enums/role' +import CurrentlyOutPage from '../pages/currentlyOut' + +context('En Route Page', () => { + beforeEach(() => { + cy.task('reset') + cy.setupUserAuth({ + roles: [`ROLE_PRISON`, `ROLE_${Role.GlobalSearch}`], + caseLoads: [ + { caseloadFunction: '', caseLoadId: 'LEI', currentlyActive: true, description: 'Leeds (HMP)', type: '' }, + ], + }) + cy.task('stubOutToday', '123') + cy.task('stubPostSearchPrisonersById') + cy.task('stubRecentMovements') + cy.task('stubGetLocation') + cy.signIn({ redirectPath: '/establishment-roll/123/currently-out' }) + cy.visit('/establishment-roll/123/currently-out') + }) + + it('Page is visible', () => { + Page.verifyOnPage(CurrentlyOutPage) + }) + + it('should display a table row for each prisoner en-route', () => { + const page = Page.verifyOnPage(CurrentlyOutPage) + page.currentlyOutRows().should('have.length', 2) + + page.currentlyOutRows().first().find('td').eq(1).should('contain.text', 'Shannon, Eddie') + page.currentlyOutRows().first().find('td').eq(2).should('contain.text', 'A1234AB') + page.currentlyOutRows().first().find('td').eq(3).should('contain.text', '01/01/1980') + page.currentlyOutRows().first().find('td').eq(4).should('contain.text', '1-1-1') + page.currentlyOutRows().first().find('td').eq(5).should('contain.text', '') + page.currentlyOutRows().first().find('td').eq(6).should('contain.text', 'Sheffield') + page.currentlyOutRows().first().find('td').eq(7).should('contain.text', 'Some Sheffield comment') + }) + + it('should display alerts and category if cat A', () => { + const page = Page.verifyOnPage(CurrentlyOutPage) + page.currentlyOutRows().should('have.length', 2) + + page.currentlyOutRows().eq(1).find('td').eq(5).should('contain.text', 'Hidden disability') + page.currentlyOutRows().eq(1).find('td').eq(5).should('contain.text', 'CAT A') + }) +}) diff --git a/integration_tests/mockApis/prison.ts b/integration_tests/mockApis/prison.ts index fb288c9..bf21586 100644 --- a/integration_tests/mockApis/prison.ts +++ b/integration_tests/mockApis/prison.ts @@ -145,6 +145,22 @@ export default { }) }, + stubOutToday: (livingUnitId = 'abc') => { + return stubFor({ + request: { + method: 'GET', + urlPattern: `/prison/api/movements/livingUnit/${livingUnitId}/currently-out`, + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: movementsOutMock, + }, + }) + }, + stubMovementsInReception: (prisonCode = 'LEI') => { return stubFor({ request: { diff --git a/integration_tests/pages/currentlyOut.ts b/integration_tests/pages/currentlyOut.ts new file mode 100644 index 0000000..1226816 --- /dev/null +++ b/integration_tests/pages/currentlyOut.ts @@ -0,0 +1,9 @@ +import Page, { PageElement } from './page' + +export default class CurrentlyOutPage extends Page { + constructor() { + super(`Currently out - A-wing`) + } + + currentlyOutRows = (): PageElement => cy.get('table.currently-out-roll__table tbody tr') +} diff --git a/server/controllers/establishmentRollController.ts b/server/controllers/establishmentRollController.ts index d144646..f5cd7fb 100644 --- a/server/controllers/establishmentRollController.ts +++ b/server/controllers/establishmentRollController.ts @@ -3,11 +3,13 @@ import EstablishmentRollService from '../services/establishmentRollService' import MovementsService from '../services/movementsService' import { userHasRoles } from '../utils/utils' import { Role } from '../enums/role' +import LocationService from '../services/locationsService' export default class EstablishmentRollController { constructor( private readonly establishmentRollService: EstablishmentRollService, private readonly movementsService: MovementsService, + private readonly locationService: LocationService, ) {} public getEstablishmentRoll(): RequestHandler { @@ -32,9 +34,9 @@ export default class EstablishmentRollController { const [landingRollCounts, wing, spur, landing] = await Promise.all([ this.establishmentRollService.getLandingRollCounts(clientToken, user.activeCaseLoadId, Number(landingId)), - this.establishmentRollService.getLocationInfo(clientToken, wingId), - spurId ? this.establishmentRollService.getLocationInfo(clientToken, spurId) : null, - this.establishmentRollService.getLocationInfo(clientToken, landingId), + this.locationService.getLocationInfo(clientToken, wingId), + spurId ? this.locationService.getLocationInfo(clientToken, spurId) : null, + this.locationService.getLocationInfo(clientToken, landingId), ]) res.render('pages/establishmentRollLanding', { landingRollCounts, wing, spur, landing }) @@ -101,4 +103,21 @@ export default class EstablishmentRollController { }) } } + + public getCurrentlyOut(): RequestHandler { + return async (req: Request, res: Response) => { + const { livingUnitId } = req.params + const { clientToken } = req.middleware + + const [prisonersCurrentlyOut, location] = await Promise.all([ + this.movementsService.getOffendersCurrentlyOutOfLivingUnit(clientToken, livingUnitId), + this.locationService.getLocationInfo(clientToken, livingUnitId), + ]) + + res.render('pages/currentlyOut', { + prisoners: prisonersCurrentlyOut, + location, + }) + } + } } diff --git a/server/data/interfaces/offenderMovement.ts b/server/data/interfaces/offenderMovement.ts index f69aee8..6b4aad4 100644 --- a/server/data/interfaces/offenderMovement.ts +++ b/server/data/interfaces/offenderMovement.ts @@ -9,6 +9,8 @@ export interface OffenderMovement { fromAgencyDescription: string toAgency: string toAgencyDescription: string + toCity: string + commentText?: string movementType: 'CRT' | 'ADM' | 'REL' | 'TAP' | 'TRN' movementTypeDescription: string movementReason: string diff --git a/server/data/interfaces/prisonApiClient.ts b/server/data/interfaces/prisonApiClient.ts index a061109..6c8403b 100644 --- a/server/data/interfaces/prisonApiClient.ts +++ b/server/data/interfaces/prisonApiClient.ts @@ -35,4 +35,5 @@ export interface PrisonApiClient { getPrisonerImage(offenderNumber: string, fullSizeImage: boolean): Promise getOffenderCellHistory(bookingId: number, params?: { page: number; size: number }): Promise> getUserDetailsList(usernames: string[]): Promise + getPrisonersCurrentlyOutOfLivingUnit(livingUnitId: string): Promise } diff --git a/server/data/prisonApiClient.ts b/server/data/prisonApiClient.ts index de2cd57..63c9df7 100644 --- a/server/data/prisonApiClient.ts +++ b/server/data/prisonApiClient.ts @@ -124,4 +124,8 @@ export default class PrisonApiRestClient implements PrisonApiClient { getUserDetailsList(usernames: string[]): Promise { return this.post({ path: '/api/users/list', data: usernames }) } + + getPrisonersCurrentlyOutOfLivingUnit(livingUnitId: string): Promise { + return this.get({ path: `/api/movements/livingUnit/${livingUnitId}/currently-out` }) + } } diff --git a/server/routes/establishmentRollRouter.ts b/server/routes/establishmentRollRouter.ts index fd57484..40aa9c7 100644 --- a/server/routes/establishmentRollRouter.ts +++ b/server/routes/establishmentRollRouter.ts @@ -18,6 +18,7 @@ export default function establishmentRollRouter(services: Services): Router { const establishmentRollController = new EstablishmentRollController( services.establishmentRollService, services.movementsService, + services.locationsService, ) get('/', establishmentRollController.getEstablishmentRoll()) @@ -32,6 +33,7 @@ export default function establishmentRollRouter(services: Services): Router { get('/en-route', establishmentRollController.getEnRoute()) get('/in-reception', establishmentRollController.getInReception()) get('/no-cell-allocated', establishmentRollController.getUnallocated()) + get('/:livingUnitId/currently-out', establishmentRollController.getCurrentlyOut()) return router } diff --git a/server/services/establishmentRollService.test.ts b/server/services/establishmentRollService.test.ts index 91d3515..8777733 100644 --- a/server/services/establishmentRollService.test.ts +++ b/server/services/establishmentRollService.test.ts @@ -1,7 +1,6 @@ import EstablishmentRollService from './establishmentRollService' import prisonApiClientMock from '../test/mocks/prisonApiClientMock' import { assignedRollCountWithSpursMock, unassignedRollCountMock } from '../mocks/rollCountMock' -import { locationMock } from '../mocks/locationMock' describe('establishmentRollService', () => { let establishmentRollService: EstablishmentRollService @@ -128,14 +127,4 @@ describe('establishmentRollService', () => { expect(landingRollCounts).toEqual(assignedRollCountWithSpursMock) }) }) - - describe('getLocationInfo', () => { - it('should call api and return response', async () => { - prisonApiClientMock.getLocation = jest.fn().mockResolvedValueOnce(locationMock) - - const locationInfo = await establishmentRollService.getLocationInfo('token', 'loc') - - expect(locationInfo).toEqual(locationMock) - }) - }) }) diff --git a/server/services/establishmentRollService.ts b/server/services/establishmentRollService.ts index 25390aa..c2452f2 100644 --- a/server/services/establishmentRollService.ts +++ b/server/services/establishmentRollService.ts @@ -3,7 +3,6 @@ import { PrisonApiClient } from '../data/interfaces/prisonApiClient' import { BlockRollCount } from '../data/interfaces/blockRollCount' import EstablishmentRollCount from './interfaces/establishmentRollService/EstablishmentRollCount' import nestRollBlocks, { splitRollBlocks } from './utils/nestRollBlocks' -import { Location } from '../data/interfaces/location' const getTotals = (array: BlockRollCount[], figure: keyof BlockRollCount): number => array.reduce((accumulator, block) => accumulator + ((block[figure] as number) || 0), 0) @@ -66,10 +65,4 @@ export default class EstablishmentRollService { parentLocationId: landingId, }) } - - public getLocationInfo(clientToken: string, locationId: string): Promise { - const prisonApi = this.prisonApiClientBuilder(clientToken) - - return prisonApi.getLocation(locationId) - } } diff --git a/server/services/index.ts b/server/services/index.ts index 4c39b98..44f218b 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -8,6 +8,7 @@ import ContentfulService from './contentfulService' import ComponentService from './componentService' import EstablishmentRollService from './establishmentRollService' import MovementsService from './movementsService' +import LocationService from './locationsService' export const services = () => { const { @@ -29,6 +30,7 @@ export const services = () => { const componentService = new ComponentService(componentApiClientBuilder) const establishmentRollService = new EstablishmentRollService(prisonApiClientBuilder) const movementsService = new MovementsService(prisonApiClientBuilder, prisonerSearchApiClientBuilder) + const locationsService = new LocationService(prisonApiClientBuilder) const apolloClient = new ApolloClient({ cache: new InMemoryCache(), @@ -56,6 +58,7 @@ export const services = () => { componentService, establishmentRollService, movementsService, + locationsService, } } diff --git a/server/services/locationService.test.ts b/server/services/locationService.test.ts new file mode 100644 index 0000000..fab40ed --- /dev/null +++ b/server/services/locationService.test.ts @@ -0,0 +1,21 @@ +import prisonApiClientMock from '../test/mocks/prisonApiClientMock' +import LocationService from './locationsService' +import { locationMock } from '../mocks/locationMock' + +describe('establishmentRollService', () => { + let locationRollService: LocationService + + beforeEach(() => { + locationRollService = new LocationService(() => prisonApiClientMock) + }) + + describe('getLocationInfo', () => { + it('should call api and return response', async () => { + prisonApiClientMock.getLocation = jest.fn().mockResolvedValueOnce(locationMock) + + const locationInfo = await locationRollService.getLocationInfo('token', 'loc') + + expect(locationInfo).toEqual(locationMock) + }) + }) +}) diff --git a/server/services/locationsService.ts b/server/services/locationsService.ts new file mode 100644 index 0000000..959e4fa --- /dev/null +++ b/server/services/locationsService.ts @@ -0,0 +1,13 @@ +import { RestClientBuilder } from '../data' +import { PrisonApiClient } from '../data/interfaces/prisonApiClient' +import { Location } from '../data/interfaces/location' + +export default class LocationService { + constructor(private readonly prisonApiClientBuilder: RestClientBuilder) {} + + public async getLocationInfo(clientToken: string, locationId: string): Promise { + const prisonApi = this.prisonApiClientBuilder(clientToken) + + return prisonApi.getLocation(locationId) + } +} diff --git a/server/services/movementsService.test.ts b/server/services/movementsService.test.ts index c87f42f..c93fb2b 100644 --- a/server/services/movementsService.test.ts +++ b/server/services/movementsService.test.ts @@ -275,4 +275,79 @@ describe('movementsService', () => { expect(result).toEqual([]) }) }) + + describe('getOffendersCurrentlyOutOfLivingUnit', () => { + beforeEach(() => { + prisonApiClientMock.getPrisonersCurrentlyOutOfLivingUnit = jest.fn().mockResolvedValue(movementsOutMock) + prisonerSearchApiClientMock.getPrisonersById = jest.fn().mockResolvedValue(prisonerSearchMock) + prisonApiClientMock.getRecentMovements = jest.fn().mockResolvedValue(movementsRecentMock) + }) + + it('should search for prisoners for prisoners from getPrisonersCurrentlyOutOfLivingUnit', async () => { + const result = await movementsService.getOffendersCurrentlyOutOfLivingUnit('token', 'MDI') + expect(prisonerSearchApiClientMock.getPrisonersById).toHaveBeenCalledWith(['A1234AA', 'A1234AB']) + expect(prisonApiClientMock.getRecentMovements).toHaveBeenCalledWith(['A1234AA', 'A1234AB']) + + expect(result).toEqual([ + expect.objectContaining(prisonerSearchMock[0]), + expect.objectContaining(prisonerSearchMock[1]), + ]) + }) + + it('should decorate the alertFlags for each prisoner', async () => { + const result = await movementsService.getOffendersCurrentlyOutOfLivingUnit('token', 'MDI') + + expect(result).toEqual([ + expect.objectContaining({ + alertFlags: [], + }), + expect.objectContaining({ + alertFlags: [ + { + alertCodes: ['HID'], + alertIds: ['HID'], + classes: 'alert-status alert-status--medical', + label: 'Hidden disability', + }, + ], + }), + ]) + }) + + it('should add the currentLocation from latest movement toCity', async () => { + const result = await movementsService.getOffendersCurrentlyOutOfLivingUnit('token', 'MDI') + + expect(result).toEqual([ + expect.objectContaining({ + currentLocation: 'Sheffield', + }), + expect.objectContaining({ + currentLocation: 'Doncaster', + }), + ]) + }) + + it('should add the movementComment from latest movement commentText', async () => { + const result = await movementsService.getOffendersCurrentlyOutOfLivingUnit('token', 'MDI') + + expect(result).toEqual([ + expect.objectContaining({ + movementComment: 'Some Sheffield comment', + }), + expect.objectContaining({ + movementComment: 'Some Doncaster comment', + }), + ]) + }) + + it('should return empty api if no currently out prisoners', async () => { + prisonApiClientMock.getPrisonersCurrentlyOutOfLivingUnit = jest.fn().mockResolvedValue([]) + + const result = await movementsService.getOffendersCurrentlyOutOfLivingUnit('token', 'LEI') + expect(prisonerSearchApiClientMock.getPrisonersById).toBeCalledTimes(0) + expect(prisonApiClientMock.getRecentMovements).toBeCalledTimes(0) + + expect(result).toEqual([]) + }) + }) }) diff --git a/server/services/movementsService.ts b/server/services/movementsService.ts index 6467de7..116954a 100644 --- a/server/services/movementsService.ts +++ b/server/services/movementsService.ts @@ -169,4 +169,34 @@ export default class MovementsService { } }) } + + public async getOffendersCurrentlyOutOfLivingUnit( + clientToken: string, + livingUnitId: string, + ): Promise<(PrisonerWithAlerts & { currentLocation: string; movementComment?: string })[]> { + const prisonApi = this.prisonApiClientBuilder(clientToken) + const prisonerSearchClient = this.prisonerSearchClientBuilder(clientToken) + + const outPrisoners = await prisonApi.getPrisonersCurrentlyOutOfLivingUnit(livingUnitId) + const prisonerNumbers = outPrisoners.map(prisoner => prisoner.offenderNo) + if (!outPrisoners || !outPrisoners?.length) return [] + + const [prisoners, recentMovements] = await Promise.all([ + prisonerSearchClient.getPrisonersById(prisonerNumbers), + prisonApi.getRecentMovements(prisonerNumbers), + ]) + + return prisoners + .sort((a, b) => a.lastName.localeCompare(b.lastName, 'en', { ignorePunctuation: true })) + .map(prisoner => { + const recentMovement = recentMovements.find(movement => movement.offenderNo === prisoner.prisonerNumber) + + return { + ...prisoner, + alertFlags: mapAlerts(prisoner), + currentLocation: recentMovement?.toCity, + movementComment: recentMovement?.commentText, + } + }) + } } diff --git a/server/test/mocks/movementsEnRouteMock.ts b/server/test/mocks/movementsEnRouteMock.ts index 761a46f..d857279 100644 --- a/server/test/mocks/movementsEnRouteMock.ts +++ b/server/test/mocks/movementsEnRouteMock.ts @@ -19,6 +19,7 @@ export const movementsEnRouteMock: OffenderMovement[] = [ directionCode: 'IN', movementTime: '10:00', movementDate: '2023-12-25', + toCity: 'Doncaster', }, { offenderNo: 'A1234AB', @@ -37,5 +38,6 @@ export const movementsEnRouteMock: OffenderMovement[] = [ directionCode: 'IN', movementTime: '11:00', movementDate: '2023-12-25', + toCity: 'Sheffield', }, ] diff --git a/server/test/mocks/movementsRecentMock.ts b/server/test/mocks/movementsRecentMock.ts index 100184e..86f7afc 100644 --- a/server/test/mocks/movementsRecentMock.ts +++ b/server/test/mocks/movementsRecentMock.ts @@ -19,6 +19,8 @@ export const movementsRecentMock: OffenderMovement[] = [ directionCode: 'IN', movementTime: '10:00', movementDate: '2023-12-25', + toCity: 'Doncaster', + commentText: 'Some Doncaster comment', }, { offenderNo: 'A1234AB', @@ -37,5 +39,7 @@ export const movementsRecentMock: OffenderMovement[] = [ directionCode: 'IN', movementTime: '11:00', movementDate: '2023-12-25', + toCity: 'Sheffield', + commentText: 'Some Sheffield comment', }, ] diff --git a/server/test/mocks/prisonApiClientMock.ts b/server/test/mocks/prisonApiClientMock.ts index 57b1293..9c253ab 100644 --- a/server/test/mocks/prisonApiClientMock.ts +++ b/server/test/mocks/prisonApiClientMock.ts @@ -19,6 +19,7 @@ const prisonApiClientMock: PrisonApiClient = { getMovementsInReception: jest.fn(), getOffenderCellHistory: jest.fn(), getUserDetailsList: jest.fn(), + getPrisonersCurrentlyOutOfLivingUnit: jest.fn(), } export default prisonApiClientMock diff --git a/server/views/pages/arrivingToday.njk b/server/views/pages/arrivingToday.njk index e692e08..a9c9369 100644 --- a/server/views/pages/arrivingToday.njk +++ b/server/views/pages/arrivingToday.njk @@ -34,7 +34,7 @@ Location Time arrived Arrived from - Alert Flags + Alert flags diff --git a/server/views/pages/currentlyOut.njk b/server/views/pages/currentlyOut.njk new file mode 100644 index 0000000..d3b676d --- /dev/null +++ b/server/views/pages/currentlyOut.njk @@ -0,0 +1,61 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/alertFlags.njk" import alertFlags %} +{% from "../macros/categoryFlag.njk" import categoryFlag %} + +{% set locationName = location.userDescription if location.userDescription else location.description %} +{% set pageTitle = "Currently out - " + locationName %} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: '/' + }, + { + text: 'Establishment roll', + href: '/establishment-roll' + } +] %} + +{% block content %} +
+
+

{{ pageTitle }}

+
+ +
+
+ + + + + + + + + + + + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + + + + {% endfor %} + + +
PictureNamePrison numberDate of birthLocationAlert flagsCurrent locationComment
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.dateOfBirth | formatDate('short')}}{{ prisoner.cellLocation }}{{ alertFlags(prisoner.alertFlags) }}{{categoryFlag("", prisoner.category) | safe}}{{ prisoner.currentLocation }}{{ prisoner.movementComment }}
+
+
+
+{% endblock %} diff --git a/server/views/pages/enRoute.njk b/server/views/pages/enRoute.njk index e352a9c..4dcf4bf 100644 --- a/server/views/pages/enRoute.njk +++ b/server/views/pages/enRoute.njk @@ -34,7 +34,7 @@ Departed En route from Reason - Alert Flags + Alert flags diff --git a/server/views/pages/establishmentRoll.njk b/server/views/pages/establishmentRoll.njk index fdfd362..867199b 100644 --- a/server/views/pages/establishmentRoll.njk +++ b/server/views/pages/establishmentRoll.njk @@ -29,7 +29,7 @@ {{ block.currentlyInCell }} {% if block.currentlyOut > 0 %} - {{block.currentlyOut}} + {{block.currentlyOut}} {% else %} 0 {% endif %} {{ block.operationalCapacity }} @@ -71,7 +71,7 @@ establishmentRollStat( heading = "Arrived today", value = todayStats.inToday, - href = config.serviceUrls.digitalPrisons+"/establishment-roll/in-today", + href = "/establishment-roll/in-today", qaTag = "in-today" ) }} @@ -82,7 +82,7 @@ establishmentRollStat( heading = "In reception", value = todayStats.unassignedIn, - href = config.serviceUrls.digitalPrisons+"/establishment-roll/in-reception", + href = "/establishment-roll/in-reception", qaTag = "unassigned-in" ) }} @@ -93,7 +93,7 @@ establishmentRollStat( heading = "Still to arrive", value = todayStats.enroute, - href = config.serviceUrls.digitalPrisons+"/establishment-roll/en-route", + href = "/establishment-roll/en-route", qaTag = "enroute" ) }} @@ -105,7 +105,7 @@ establishmentRollStat( heading = "Out today", value = todayStats.outToday, - href = config.serviceUrls.digitalPrisons+"/establishment-roll/out-today", + href = "/establishment-roll/out-today", qaTag = "out-today" ) }} @@ -116,7 +116,7 @@ establishmentRollStat( heading = "No cell allocated", value = todayStats.noCellAllocated, - href = config.serviceUrls.digitalPrisons+"/establishment-roll/no-cell-allocated", + href = "/establishment-roll/no-cell-allocated", qaTag = "no-cell-allocated" ) }} diff --git a/server/views/pages/inReception.njk b/server/views/pages/inReception.njk index 6b3adfe..055d654 100644 --- a/server/views/pages/inReception.njk +++ b/server/views/pages/inReception.njk @@ -33,7 +33,7 @@ Date of birth Time arrived Arrived from - Alert Flags + Alert flags diff --git a/server/views/pages/outToday.njk b/server/views/pages/outToday.njk index 38f4ed9..5381bc7 100644 --- a/server/views/pages/outToday.njk +++ b/server/views/pages/outToday.njk @@ -33,7 +33,7 @@ Date of birth Time out Reason - Alert Flags + Alert flags