diff --git a/integration_tests/pages/bookings/cancelledVisitsPage.ts b/integration_tests/pages/bookings/cancelledVisitsPage.ts
index d8126ece..41a7a1a9 100644
--- a/integration_tests/pages/bookings/cancelledVisitsPage.ts
+++ b/integration_tests/pages/bookings/cancelledVisitsPage.ts
@@ -11,5 +11,5 @@ export default class CancelledVisitsPage extends Page {
visitEndTime = (index: number): PageElement => cy.get(`[data-test=visit-end-time-${index}]`)
- visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-${index}]`)
+ visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-booking-${index}]`)
}
diff --git a/integration_tests/pages/bookings/index.ts b/integration_tests/pages/bookings/index.ts
index 5b419830..93570f11 100644
--- a/integration_tests/pages/bookings/index.ts
+++ b/integration_tests/pages/bookings/index.ts
@@ -13,7 +13,7 @@ export default class BookingsPage extends Page {
visitReference = (index: number): PageElement => cy.get(`[data-test=visit-reference-${index}]`)
- visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-${index}]`)
+ visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-booking-${index}]`)
pastVisitsLink = (): PageElement => cy.get('[data-test="past-visits-link"]')
diff --git a/integration_tests/pages/bookings/pastVisitsPage.ts b/integration_tests/pages/bookings/pastVisitsPage.ts
index 7fd1e3ac..58df70b4 100644
--- a/integration_tests/pages/bookings/pastVisitsPage.ts
+++ b/integration_tests/pages/bookings/pastVisitsPage.ts
@@ -11,5 +11,5 @@ export default class PastVisitsPage extends Page {
visitEndTime = (index: number): PageElement => cy.get(`[data-test=visit-end-time-${index}]`)
- visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-${index}]`)
+ visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-booking-${index}]`)
}
diff --git a/server/@types/orchestration-api.d.ts b/server/@types/orchestration-api.d.ts
index 5249d792..7121f7d8 100644
--- a/server/@types/orchestration-api.d.ts
+++ b/server/@types/orchestration-api.d.ts
@@ -768,6 +768,8 @@ export interface components {
| 'NOT_KNOWN'
| 'NOT_APPLICABLE'
| 'BY_PRISONER'
+ /** @description Username for user who actioned this request */
+ actionedBy: string
}
/**
* @description Contact Phone Number
@@ -800,6 +802,7 @@ export interface components {
| 'VISIT_ORDER_CANCELLED'
| 'SUPERSEDED_CANCELLATION'
| 'DETAILS_CHANGED_AFTER_BOOKING'
+ | 'BOOKER_CANCELLED'
/**
* @description Outcome text
* @example Because he got covid
@@ -902,6 +905,7 @@ export interface components {
| 'VISIT_ORDER_CANCELLED'
| 'SUPERSEDED_CANCELLATION'
| 'DETAILS_CHANGED_AFTER_BOOKING'
+ | 'BOOKER_CANCELLED'
/**
* @description Visit Restriction
* @example OPEN
@@ -1325,9 +1329,9 @@ export interface components {
sort?: components['schemas']['SortObject'][]
/** Format: int32 */
pageSize?: number
- paged?: boolean
/** Format: int32 */
pageNumber?: number
+ paged?: boolean
unpaged?: boolean
}
SortObject: {
@@ -1616,6 +1620,7 @@ export interface components {
| 'VISIT_ORDER_CANCELLED'
| 'SUPERSEDED_CANCELLATION'
| 'DETAILS_CHANGED_AFTER_BOOKING'
+ | 'BOOKER_CANCELLED'
/**
* Format: date-time
* @description The date and time of the visit
diff --git a/server/constants/paths.ts b/server/constants/paths.ts
index 9e4d0d57..ce894135 100644
--- a/server/constants/paths.ts
+++ b/server/constants/paths.ts
@@ -27,6 +27,10 @@ const paths = {
VISIT: '/bookings/details',
VISIT_PAST: '/bookings/past/details',
VISIT_CANCELLED: '/bookings/cancelled/details',
+
+ // Cancel journey
+ CANCEL_VISIT: '/bookings/cancel-booking',
+ CANCEL_CONFIRMATION: '/bookings/booking-cancelled',
},
VISITORS: '/visitors',
diff --git a/server/data/orchestrationApiClient.test.ts b/server/data/orchestrationApiClient.test.ts
index ec720f9f..e1d6373a 100644
--- a/server/data/orchestrationApiClient.test.ts
+++ b/server/data/orchestrationApiClient.test.ts
@@ -7,6 +7,7 @@ import {
AvailableVisitSessionDto,
AvailableVisitSessionRestrictionDto,
BookingOrchestrationRequestDto,
+ CancelVisitOrchestrationDto,
ChangeApplicationDto,
CreateApplicationDto,
VisitDto,
@@ -53,6 +54,30 @@ describe('orchestrationApiClient', () => {
})
})
+ describe('cancelVisit', () => {
+ it('should cancel a visit for the booker', async () => {
+ const applicationReference = 'aaa-bbb-ccc'
+
+ fakeOrchestrationApi
+ .put(`/visits/${applicationReference}/cancel`, {
+ cancelOutcome: {
+ outcomeStatus: 'BOOKER_CANCELLED',
+ },
+ applicationMethodType: 'WEBSITE',
+ actionedBy: bookerReference.value,
+ })
+ .matchHeader('authorization', `Bearer ${token}`)
+ .reply(200)
+
+ await orchestrationApiClient.cancelVisit({
+ applicationReference,
+ actionedBy: bookerReference.value,
+ })
+
+ expect(fakeOrchestrationApi.isDone()).toBe(true)
+ })
+ })
+
describe('getFuturePublicVisits', () => {
it('should retrieve all future visits associated with a booker', async () => {
const visits = [TestData.orchestrationVisitDto({ outcomeStatus: null })]
diff --git a/server/data/orchestrationApiClient.ts b/server/data/orchestrationApiClient.ts
index 333c44c3..f99fb54d 100644
--- a/server/data/orchestrationApiClient.ts
+++ b/server/data/orchestrationApiClient.ts
@@ -14,6 +14,7 @@ import {
VisitDto,
VisitorInfoDto,
AvailableVisitSessionRestrictionDto,
+ CancelVisitOrchestrationDto,
} from './orchestrationApiTypes'
export type SessionRestriction = AvailableVisitSessionDto['sessionRestriction']
@@ -44,6 +45,25 @@ export default class OrchestrationApiClient {
})
}
+ async cancelVisit({
+ applicationReference,
+ actionedBy,
+ }: {
+ applicationReference: string
+ actionedBy: string
+ }): Promise {
+ await this.restClient.put({
+ path: `/visits/${applicationReference}/cancel`,
+ data: {
+ cancelOutcome: {
+ outcomeStatus: 'BOOKER_CANCELLED',
+ },
+ applicationMethodType: 'WEBSITE',
+ actionedBy,
+ },
+ })
+ }
+
async getFuturePublicVisits(bookerReference: string): Promise {
return this.restClient.get({ path: `/public/booker/${bookerReference}/visits/booked/future` })
}
diff --git a/server/data/orchestrationApiTypes.ts b/server/data/orchestrationApiTypes.ts
index 0ca2b92b..7f506acc 100644
--- a/server/data/orchestrationApiTypes.ts
+++ b/server/data/orchestrationApiTypes.ts
@@ -16,6 +16,8 @@ export type BookerPrisonerInfoDto = components['schemas']['BookerPrisonerInfoDto
export type BookerReference = components['schemas']['BookerReference']
+export type CancelVisitOrchestrationDto = components['schemas']['CancelVisitOrchestrationDto']
+
export type ChangeApplicationDto = components['schemas']['ChangeApplicationDto']
export type CreateApplicationDto = components['schemas']['CreateApplicationDto']
diff --git a/server/routes/bookings/bookingDetailsController.ts b/server/routes/bookings/bookingDetailsController.ts
index c9579eb1..14040468 100644
--- a/server/routes/bookings/bookingDetailsController.ts
+++ b/server/routes/bookings/bookingDetailsController.ts
@@ -30,12 +30,17 @@ export default class BookingDetailsController {
const prison = await this.prisonService.getPrison(visit.prisonId)
+ const nowTimestamp = new Date()
+ const visitStartTimestamp = new Date(visit.startTimestamp)
+ const showCancel = nowTimestamp < visitStartTimestamp
+
return res.render('pages/bookings/visit', {
booker,
prison,
prisoner,
type,
visit,
+ showCancel,
showServiceNav: true,
})
}
diff --git a/server/routes/bookings/bookingsController.test.ts b/server/routes/bookings/bookingsController.test.ts
index 06473eea..f49fd0c5 100644
--- a/server/routes/bookings/bookingsController.test.ts
+++ b/server/routes/bookings/bookingsController.test.ts
@@ -51,9 +51,12 @@ describe('Bookings homepage (future visits)', () => {
expect($('[data-test="visit-start-time-1"]').text()).toBe('10am')
expect($('[data-test="visit-end-time-1"]').text()).toBe('11:30am')
expect($('[data-test="visit-reference-1"]').text()).toBe('ab-cd-ef-gh')
- expect($('[data-test="visit-link-1"]').attr('href')).toBe(
+ expect($('[data-test="visit-link-booking-1"]').attr('href')).toBe(
`${paths.BOOKINGS.VISIT}/${futureVisitDetails.visitDisplayId}`,
)
+ expect($('[data-test="visit-link-cancel-1"]').attr('href')).toBe(
+ `${paths.BOOKINGS.CANCEL_VISIT}/${futureVisitDetails.visitDisplayId}`,
+ )
expect($('[data-test=change-booking-heading]').length).toBeTruthy()
expect($('[data-test="prison-name"]').text()).toBe(prison.prisonName)
@@ -122,9 +125,10 @@ describe('Past visits page', () => {
expect($('[data-test="visit-date-1"]').text()).toBe('Thursday 30 May 2024')
expect($('[data-test="visit-start-time-1"]').text()).toBe('10am')
expect($('[data-test="visit-end-time-1"]').text()).toBe('11:30am')
- expect($('[data-test="visit-link-1"]').attr('href')).toBe(
+ expect($('[data-test="visit-link-booking-1"]').attr('href')).toBe(
`${paths.BOOKINGS.VISIT_PAST}/${pastVisitDetails.visitDisplayId}`,
)
+ expect($('[data-test="visit-link-cancel-1"]').attr('href')).toBe(undefined)
expect($('[data-test="no-visits"]').length).toBeFalsy()
@@ -189,9 +193,10 @@ describe('Cancelled visits page', () => {
expect($('[data-test="visit-date-1"]').text()).toBe('Thursday 30 May 2024')
expect($('[data-test="visit-start-time-1"]').text()).toBe('10am')
expect($('[data-test="visit-end-time-1"]').text()).toBe('11:30am')
- expect($('[data-test="visit-link-1"]').attr('href')).toBe(
+ expect($('[data-test="visit-link-booking-1"]').attr('href')).toBe(
`${paths.BOOKINGS.VISIT_CANCELLED}/${cancelledVisitDetails.visitDisplayId}`,
)
+ expect($('[data-test="visit-link-cancel-1"]').attr('href')).toBe(undefined)
expect($('[data-test="no-visits"]').length).toBeFalsy()
diff --git a/server/routes/bookings/cancelVisitController.test.ts b/server/routes/bookings/cancelVisitController.test.ts
new file mode 100644
index 00000000..52d36f6b
--- /dev/null
+++ b/server/routes/bookings/cancelVisitController.test.ts
@@ -0,0 +1,164 @@
+import type { Express } from 'express'
+import request from 'supertest'
+import * as cheerio from 'cheerio'
+import { SessionData } from 'express-session'
+import { randomUUID } from 'crypto'
+import { FieldValidationError } from 'express-validator'
+import { appWithAllRoutes, flashProvider } from '../testutils/appSetup'
+import { createMockBookerService, createMockVisitService } from '../../services/testutils/mocks'
+import TestData from '../testutils/testData'
+import paths from '../../constants/paths'
+import { VisitDetails } from '../../services/visitService'
+
+let app: Express
+
+const bookerService = createMockBookerService()
+const visitService = createMockVisitService()
+
+const bookerReference = TestData.bookerReference().value
+const prisoner = TestData.prisoner()
+const visitDisplayId = randomUUID()
+
+let sessionData: SessionData
+let bookings: SessionData['bookings']
+let visitDetails: VisitDetails
+
+beforeEach(() => {
+ visitDetails = TestData.visitDetails({ visitDisplayId })
+ bookings = { type: 'future', visits: [visitDetails] }
+
+ sessionData = {
+ booker: {
+ reference: bookerReference,
+ prisoners: [prisoner],
+ },
+ bookings,
+ } as SessionData
+
+ app = appWithAllRoutes({ services: { bookerService, visitService }, sessionData })
+})
+
+afterEach(() => {
+ jest.resetAllMocks()
+})
+
+describe('Cancel a booking', () => {
+ describe('GET - Display visit information on cancellation page', () => {
+ it('should render the cancel confirmation page', () => {
+ return request(app)
+ .get(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`)
+ .expect('Content-Type', /html/)
+ .expect(res => {
+ const $ = cheerio.load(res.text)
+ expect($('title').text()).toMatch(/^Cancel your booking -/)
+ expect($('[data-test="back-link"]').attr('href')).toBe(
+ `${paths.BOOKINGS.VISIT}/${visitDetails.visitDisplayId}`,
+ )
+ expect($('h1').text()).toBe('Are you sure you want to cancel your booking?')
+
+ expect($('[data-test="visit-date"]').text()).toBe('Thursday 30 May 2024')
+ expect($('[data-test="visit-start-time"]').text()).toBe('10am')
+ expect($('[data-test="visit-end-time"]').text()).toBe('11:30am')
+ expect($('[data-test="prisoner-name"]').text()).toBe('John Smith')
+ expect($('[data-test="visitor-name-1"]').text()).toContain('Keith Phillips')
+ expect($('form[method=POST]').attr('action')).toBe(
+ `${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`,
+ )
+ })
+ })
+
+ describe('GET - Display cancellation confirmed page', () => {
+ it('should render the page confirming the visit has been cancelled', () => {
+ return request(app)
+ .get(`${paths.BOOKINGS.CANCEL_CONFIRMATION}`)
+ .expect('Content-Type', /html/)
+ .expect(res => {
+ const $ = cheerio.load(res.text)
+ expect($('title').text()).toMatch(/^Booking cancelled -/)
+ expect($('[data-test="back-link"]').attr('href')).toBe(undefined)
+ expect($('h1').text()).toContain('Booking cancelled')
+ expect($('h2').text()).toContain('What happens next')
+ expect($('p').text()).toContain(
+ 'The main contact for this booking will get a text message to confirm it has been cancelled.',
+ )
+ })
+ })
+ })
+ })
+
+ describe('POST - cancel booking', () => {
+ it('should have cancelled the visit and redirect to confirmation page', () => {
+ return request(app)
+ .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`)
+ .send('cancelBooking=yes')
+ .expect(302)
+ .expect('location', paths.BOOKINGS.CANCEL_CONFIRMATION)
+ .expect(() => {
+ expect(visitService.cancelVisit).toHaveBeenCalledTimes(1)
+ expect(visitService.cancelVisit).toHaveBeenCalledWith({
+ actionedBy: 'aaaa-bbbb-cccc',
+ applicationReference: 'ab-cd-ef-gh',
+ })
+ })
+ })
+
+ it('should redirect to visit details page if "no" is selected', () => {
+ return request(app)
+ .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`)
+ .send('cancelBooking=no')
+ .expect(302)
+ .expect('location', `${paths.BOOKINGS.VISIT}/${visitDetails.visitDisplayId}`)
+ .expect(() => {
+ expect(visitService.cancelVisit).toHaveBeenCalledTimes(0)
+ })
+ })
+
+ it('should NOT redirect when incorrect value posted', () => {
+ const expectedValidationError: FieldValidationError = {
+ location: 'body',
+ msg: 'No answer selected',
+ path: 'cancelBooking',
+ type: 'field',
+ value: 'test',
+ }
+ return request(app)
+ .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`)
+ .send('cancelBooking=test')
+ .expect(302)
+ .expect('location', `${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`)
+ .expect(() => {
+ expect(visitService.cancelVisit).toHaveBeenCalledTimes(0)
+ expect(flashProvider).toHaveBeenCalledWith('errors', [expectedValidationError])
+ })
+ })
+
+ it('should NOT redirect when no value posted', () => {
+ const expectedValidationError: FieldValidationError = {
+ location: 'body',
+ msg: 'No answer selected',
+ path: 'cancelBooking',
+ type: 'field',
+ value: undefined,
+ }
+ return request(app)
+ .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`)
+ .expect(302)
+ .expect('location', `${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`)
+ .expect(() => {
+ expect(visitService.cancelVisit).toHaveBeenCalledTimes(0)
+ expect(flashProvider).toHaveBeenCalledWith('errors', [expectedValidationError])
+ })
+ })
+
+ it('should NOT cancel the visit if invalid visit ID is posted', () => {
+ return request(app)
+ .post(`${paths.BOOKINGS.CANCEL_VISIT}/test`)
+ .send('cancelBooking=yes')
+ .expect(302)
+ .expect('location', paths.BOOKINGS.HOME)
+ .expect(() => {
+ expect(visitService.cancelVisit).toHaveBeenCalledTimes(0)
+ })
+ })
+ })
+})
diff --git a/server/routes/bookings/cancelVisitController.ts b/server/routes/bookings/cancelVisitController.ts
new file mode 100644
index 00000000..a7a516f4
--- /dev/null
+++ b/server/routes/bookings/cancelVisitController.ts
@@ -0,0 +1,112 @@
+import type { RequestHandler } from 'express'
+import { Meta, ValidationChain, matchedData, param, body, validationResult } from 'express-validator'
+import { BookerService, VisitService } from '../../services'
+import paths from '../../constants/paths'
+
+export default class CancelVisitController {
+ public constructor(
+ private readonly bookerService: BookerService,
+ private readonly visitService: VisitService,
+ ) {}
+
+ public confirmCancelView(): RequestHandler {
+ return async (req, res) => {
+ const { booker, bookings } = req.session
+ const { visits } = bookings
+
+ const errors = validationResult(req)
+ if (!errors.isEmpty() || bookings.type !== 'future') {
+ return res.redirect(paths.BOOKINGS.HOME)
+ }
+
+ const prisoner = booker.prisoners?.[0]
+ ? booker.prisoners?.[0]
+ : (await this.bookerService.getPrisoners(booker.reference))?.[0]
+
+ const { visitDisplayId } = matchedData<{ visitDisplayId: string }>(req)
+
+ const visit = visits.find(v => v.visitDisplayId === visitDisplayId)
+
+ return res.render('pages/bookings/cancel/cancel', {
+ errors: req.flash('errors'),
+ booker,
+ prisoner,
+ visit,
+ visitDisplayId,
+ showServiceNav: true,
+ })
+ }
+ }
+
+ public submit(): RequestHandler {
+ return async (req, res, next) => {
+ const { booker, bookings } = req.session
+ const { visits } = bookings
+ const { cancelBooking, visitDisplayId } = matchedData<{
+ cancelBooking: 'yes' | 'no'
+ visitDisplayId: string
+ }>(req)
+
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ req.flash('errors', errors.array())
+
+ if (!visitDisplayId) {
+ return res.redirect(paths.BOOKINGS.HOME)
+ }
+
+ return res.redirect(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDisplayId}`)
+ }
+
+ if (cancelBooking === 'no') {
+ return res.redirect(`${paths.BOOKINGS.VISIT}/${visitDisplayId}`)
+ }
+
+ const visit = visits.find(v => v.visitDisplayId === visitDisplayId)
+
+ await this.visitService.cancelVisit({
+ applicationReference: visit.reference,
+ actionedBy: booker.reference,
+ })
+
+ return res.redirect(paths.BOOKINGS.CANCEL_CONFIRMATION)
+ }
+ }
+
+ public visitCancelled(): RequestHandler {
+ return async (req, res) => {
+ return res.render('pages/bookings/cancel/cancelConfirmation', {
+ showServiceNav: true,
+ })
+ }
+ }
+
+ public validateDisplayId(): ValidationChain[] {
+ return [
+ param('visitDisplayId')
+ .isUUID()
+ .bail()
+ .custom((visitDisplayId: string, { req }: Meta & { req: Express.Request }) => {
+ const { bookings } = req.session
+ const visits = bookings?.visits ?? []
+
+ return visits.some(visit => visit.visitDisplayId === visitDisplayId)
+ }),
+ ]
+ }
+
+ public validateCancelChoice(): ValidationChain[] {
+ return [
+ body('cancelBooking').isIn(['yes', 'no']).withMessage('No answer selected'),
+ param('visitDisplayId')
+ .isUUID()
+ .bail()
+ .custom((visitDisplayId: string, { req }: Meta & { req: Express.Request }) => {
+ const { bookings } = req.session
+ const visits = bookings?.visits ?? []
+
+ return visits.some(visit => visit.visitDisplayId === visitDisplayId)
+ }),
+ ]
+ }
+}
diff --git a/server/routes/bookings/index.ts b/server/routes/bookings/index.ts
index 545ae692..5c1b5a82 100644
--- a/server/routes/bookings/index.ts
+++ b/server/routes/bookings/index.ts
@@ -5,6 +5,7 @@ import asyncMiddleware from '../../middleware/asyncMiddleware'
import paths from '../../constants/paths'
import BookingsController from './bookingsController'
import BookingDetailsController from './bookingDetailsController'
+import CancelVisitController from './cancelVisitController'
export default function routes(services: Services): Router {
const router = Router()
@@ -12,9 +13,12 @@ export default function routes(services: Services): Router {
const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler))
const getWithValidation = (path: string | string[], validationChain: ValidationChain[], handler: RequestHandler) =>
router.get(path, ...validationChain, asyncMiddleware(handler))
+ const postWithValidation = (path: string | string[], validationChain: ValidationChain[], handler: RequestHandler) =>
+ router.post(path, ...validationChain, asyncMiddleware(handler))
const bookingsController = new BookingsController(services.prisonService, services.visitService)
const bookingDetailsController = new BookingDetailsController(services.bookerService, services.prisonService)
+ const cancelVisitController = new CancelVisitController(services.bookerService, services.visitService)
get(paths.BOOKINGS.HOME, bookingsController.view('future'))
get(paths.BOOKINGS.PAST, bookingsController.view('past'))
@@ -38,5 +42,19 @@ export default function routes(services: Services): Router {
bookingDetailsController.view('cancelled'),
)
+ getWithValidation(
+ `${paths.BOOKINGS.CANCEL_VISIT}/:visitDisplayId`,
+ cancelVisitController.validateDisplayId(),
+ cancelVisitController.confirmCancelView(),
+ )
+
+ postWithValidation(
+ `${paths.BOOKINGS.CANCEL_VISIT}/:visitDisplayId`,
+ cancelVisitController.validateCancelChoice(),
+ cancelVisitController.submit(),
+ )
+
+ get(`${paths.BOOKINGS.CANCEL_CONFIRMATION}`, cancelVisitController.visitCancelled())
+
return router
}
diff --git a/server/services/visitService.test.ts b/server/services/visitService.test.ts
index 68549ab3..5832e47d 100644
--- a/server/services/visitService.test.ts
+++ b/server/services/visitService.test.ts
@@ -168,6 +168,22 @@ describe('Visit service', () => {
expect(results).toStrictEqual(visit)
})
})
+
+ describe('cancelVisit', () => {
+ it('should cancel a visit for the booker', async () => {
+ orchestrationApiClient.cancelVisit.mockResolvedValue()
+
+ await visitService.cancelVisit({
+ applicationReference: bookingJourney.applicationReference,
+ actionedBy: 'aaaa-bbbb-cccc',
+ })
+
+ expect(orchestrationApiClient.cancelVisit).toHaveBeenCalledWith({
+ applicationReference: bookingJourney.applicationReference,
+ actionedBy: 'aaaa-bbbb-cccc',
+ })
+ })
+ })
})
describe('Booking listings', () => {
diff --git a/server/services/visitService.ts b/server/services/visitService.ts
index 67e0db1f..43ecac28 100644
--- a/server/services/visitService.ts
+++ b/server/services/visitService.ts
@@ -90,6 +90,21 @@ export default class VisitService {
return visit
}
+ async cancelVisit({
+ applicationReference,
+ actionedBy,
+ }: {
+ applicationReference: string
+ actionedBy: string
+ }): Promise {
+ const token = await this.hmppsAuthClient.getSystemClientToken()
+ const orchestrationApiClient = this.orchestrationApiClientFactory(token)
+
+ await orchestrationApiClient.cancelVisit({ applicationReference, actionedBy })
+
+ logger.info(`Visit '${applicationReference}' has been cancelled by booker '${actionedBy}`)
+ }
+
async getFuturePublicVisits(bookerReference: string): Promise {
const token = await this.hmppsAuthClient.getSystemClientToken()
const orchestrationApiClient = this.orchestrationApiClientFactory(token)
diff --git a/server/views/pages/bookings/cancel/cancel.njk b/server/views/pages/bookings/cancel/cancel.njk
new file mode 100644
index 00000000..0af92ada
--- /dev/null
+++ b/server/views/pages/bookings/cancel/cancel.njk
@@ -0,0 +1,70 @@
+{% extends "../../../partials/layout.njk" %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/radios/macro.njk" import govukRadios %}
+
+{% set pageTitle = "Cancel your booking" %}
+
+{% set backLinkHref = paths.BOOKINGS.VISIT + '/' + visit.visitDisplayId %}
+
+{% block content %}
+
+
+
+ {% include "partials/errorSummary.njk" %}
+
+
Are you sure you want to cancel your booking?
+
+
Date and time
+
+
{{ visit.startTimestamp | formatDate(dateFormats.PRETTY_DATE) }}
+
+ {{ visit.startTimestamp | formatTimeFromDateTime }}
+ to
+ {{ visit.endTimestamp | formatTimeFromDateTime }}
+
+
+
Prisoner
+
+
{{ prisoner.firstName | capitalize }} {{ prisoner.lastName | capitalize }}
+
+
Visitors
+
+ {% for visitor in visit.visitors %}
+
{{ visitor.firstName }} {{ visitor.lastName }}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/server/views/pages/bookings/cancel/cancelConfirmation.njk b/server/views/pages/bookings/cancel/cancelConfirmation.njk
new file mode 100644
index 00000000..3c7c6d12
--- /dev/null
+++ b/server/views/pages/bookings/cancel/cancelConfirmation.njk
@@ -0,0 +1,20 @@
+{% extends "../../../partials/layout.njk" %}
+{% from "govuk/components/panel/macro.njk" import govukPanel %}
+
+{% set pageTitle = "Booking cancelled" %}
+
+{% block content %}
+
+
+
+ {{ govukPanel({
+ titleText: pageTitle
+ }) }}
+
+
What happens next
+
+
The main contact for this booking will get a text message to confirm it has been cancelled.
+
+
+
+{% endblock %}
diff --git a/server/views/pages/bookings/cancelled.njk b/server/views/pages/bookings/cancelled.njk
index d57c1d94..c91a1312 100644
--- a/server/views/pages/bookings/cancelled.njk
+++ b/server/views/pages/bookings/cancelled.njk
@@ -22,7 +22,12 @@
{{ visit.endTimestamp | formatTimeFromDateTime }}
- View booking details
+
+ View details
+
+ of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }}
+
+
{% endfor %}
diff --git a/server/views/pages/bookings/future.njk b/server/views/pages/bookings/future.njk
index faeb400b..111a89f2 100644
--- a/server/views/pages/bookings/future.njk
+++ b/server/views/pages/bookings/future.njk
@@ -25,7 +25,16 @@
Booking reference: {{ visit.reference }}
- View booking details
+
+ View details
+
+ of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }}
+
+
+
+ Cancel booking
+ of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }}
+
{% endfor %}
diff --git a/server/views/pages/bookings/past.njk b/server/views/pages/bookings/past.njk
index 781dac80..ca75caac 100644
--- a/server/views/pages/bookings/past.njk
+++ b/server/views/pages/bookings/past.njk
@@ -22,7 +22,12 @@
{{ visit.endTimestamp | formatTimeFromDateTime }}
- View booking details
+
+ View details
+
+ of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }}
+
+
{% endfor %}
diff --git a/server/views/pages/bookings/visit.njk b/server/views/pages/bookings/visit.njk
index fe3eae65..3e16c1f1 100644
--- a/server/views/pages/bookings/visit.njk
+++ b/server/views/pages/bookings/visit.njk
@@ -2,6 +2,7 @@
{% extends "../../partials/layout.njk" %}
{% from "govuk/components/warning-text/macro.njk" import govukWarningText %}
{%-from "moj/components/banner/macro.njk" import mojBanner %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
{% set pageTitle = "Visit booking details" %}
@@ -71,6 +72,16 @@
{{ visit.visitContact.name }}
{{ visit.visitContact.telephone | d("No phone number provided", true) }}
+ {% if showCancel %}
+ {{ govukButton({
+ text: "Cancel booking",
+ classes: "govuk-!-margin-top-5 govuk-button--secondary",
+ href: paths.BOOKINGS.CANCEL_VISIT + '/' + visit.visitDisplayId,
+ attributes: { "data-test": "cancel-visit" },
+ preventDoubleClick: true
+ }) }}
+ {% endif %}
+
{% if type === 'future' %}
{% include "partials/howToChangeBooking.njk" %}