diff --git a/cypress_shared/pages/page.ts b/cypress_shared/pages/page.ts index 1ed3f2aa9..8be45e293 100644 --- a/cypress_shared/pages/page.ts +++ b/cypress_shared/pages/page.ts @@ -235,12 +235,6 @@ export default abstract class Page extends Component { cy.get('button').contains('Print this page') } - shouldHaveATimeline(): void { - cy.get('h2').contains('Referral history') - - cy.get('.moj-timeline').contains('Referral submitted') - } - shouldPrint(environment: 'integration'): void { if (environment === 'integration') { cy.window().then(win => { diff --git a/integration_tests/mockApis/applications.ts b/integration_tests/mockApis/applications.ts index d1b273b2c..de60912a3 100644 --- a/integration_tests/mockApis/applications.ts +++ b/integration_tests/mockApis/applications.ts @@ -64,21 +64,6 @@ export default { jsonBody: args.application, }, }), - stubApplicationReferralHistoryGet: (args: { - application: TemporaryAccommodationApplication - referralNotes - }): SuperAgentRequest => - stubFor({ - request: { - method: 'GET', - url: `/cas3/timeline/${args.application.assessmentId}`, - }, - response: { - status: 200, - headers: { 'Content-Type': 'application/json;charset=UTF-8' }, - jsonBody: args.referralNotes, - }, - }), stubApplicationDocuments: (args: { application: TemporaryAccommodationApplication documents: Array diff --git a/integration_tests/tests/apply/apply.cy.ts b/integration_tests/tests/apply/apply.cy.ts index fe2bad026..1685c9ed6 100644 --- a/integration_tests/tests/apply/apply.cy.ts +++ b/integration_tests/tests/apply/apply.cy.ts @@ -1,4 +1,3 @@ -import { fakerEN_GB as faker } from '@faker-js/faker' import { ApplicationFullPage, EnterCRNPage, @@ -20,7 +19,6 @@ import { activeOffenceFactory, applicationFactory, personFactory, - referralHistorySystemNoteFactory, risksFactory, tierEnvelopeFactory, } from '../../../server/testutils/factories' @@ -305,12 +303,10 @@ context('Apply', () => { it('shows the full submitted application', function test() { // Given there is a complete and submitted application - const application = { ...this.application, status: 'submitted', assessmentId: faker.string.uuid() } - const referralNotes = [referralHistorySystemNoteFactory.build({ category: 'submitted' })] + const application = { ...this.application, status: 'submitted' } cy.task('stubApplications', [application]) cy.task('stubApplicationGet', { application }) - cy.task('stubApplicationReferralHistoryGet', { application, referralNotes }) // When I visit the application listing page const listPage = ListPage.visit([], [application]) @@ -325,7 +321,6 @@ context('Apply', () => { const applicationFullPage = Page.verifyOnPage(ApplicationFullPage, application) applicationFullPage.shouldShowPrintButton() - applicationFullPage.shouldHaveATimeline() applicationFullPage.shouldPrint('integration') // Then I should see the full application diff --git a/server/controllers/apply/applicationsController.test.ts b/server/controllers/apply/applicationsController.test.ts index c54eceec7..8b9183d5f 100644 --- a/server/controllers/apply/applicationsController.test.ts +++ b/server/controllers/apply/applicationsController.test.ts @@ -3,7 +3,7 @@ import type { NextFunction, Request, Response } from 'express' import type { TemporaryAccommodationApplication } from '@approved-premises/api' import type { ErrorsAndUserInput, GroupedApplications } from '@approved-premises/ui' -import { ApplicationService, PersonService, TimelineService } from '../../services' +import { ApplicationService, PersonService } from '../../services' import TasklistService from '../../services/tasklistService' import { activeOffenceFactory, applicationFactory, personFactory } from '../../testutils/factories' import { catchValidationErrorOrPropogate, fetchErrorsAndUserInput, insertGenericError } from '../../utils/validation' @@ -29,13 +29,12 @@ describe('applicationsController', () => { const next: DeepMocked = jest.fn() const applicationService = createMock({}) - const timelineService = createMock({}) const personService = createMock({}) let applicationsController: ApplicationsController beforeEach(() => { - applicationsController = new ApplicationsController(applicationService, timelineService, personService) + applicationsController = new ApplicationsController(applicationService, personService) request = createMock() response = createMock({}) ;(extractCallConfig as jest.MockedFn).mockReturnValue(callConfig) diff --git a/server/controllers/apply/applicationsController.ts b/server/controllers/apply/applicationsController.ts index 8658f36ca..86dc15333 100644 --- a/server/controllers/apply/applicationsController.ts +++ b/server/controllers/apply/applicationsController.ts @@ -8,12 +8,10 @@ import { firstPageOfApplicationJourney, getResponses } from '../../utils/applica import { DateFormats } from '../../utils/dateUtils' import extractCallConfig from '../../utils/restUtils' import { catchValidationErrorOrPropogate, fetchErrorsAndUserInput, insertGenericError } from '../../utils/validation' -import TimelineService from '../../services/assessments/timelineService' export default class ApplicationsController { constructor( private readonly applicationService: ApplicationService, - private readonly timelineService: TimelineService, private readonly personService: PersonService, ) {} @@ -145,11 +143,8 @@ export default class ApplicationsController { const { id } = req.params const application = await this.applicationService.findApplication(callConfig, id) - const timelineData = await this.timelineService.getTimelineForAssessment(callConfig, application.assessmentId) - return res.render('applications/full', { application, - timelineData, }) } } diff --git a/server/controllers/apply/index.ts b/server/controllers/apply/index.ts index 5472f31c1..27735f2b9 100644 --- a/server/controllers/apply/index.ts +++ b/server/controllers/apply/index.ts @@ -9,8 +9,8 @@ import PeopleController from './peopleController' import type { Services } from '../../services' export const controllers = (services: Services) => { - const { applicationService, timelineService, personService, referenceDataService } = services - const applicationsController = new ApplicationsController(applicationService, timelineService, personService) + const { applicationService, personService, referenceDataService } = services + const applicationsController = new ApplicationsController(applicationService, personService) const pagesController = new PagesController(applicationService, { personService, applicationService, diff --git a/server/data/index.ts b/server/data/index.ts index ad43cbfe6..583835ac4 100644 --- a/server/data/index.ts +++ b/server/data/index.ts @@ -26,7 +26,6 @@ import { CallConfig } from './restClient' import RoomClient from './roomClient' import TokenStore from './tokenStore' import UserClient from './userClient' -import TimelineClient from './timelineClient' type RestClientBuilder = (callConfig: CallConfig) => T @@ -47,8 +46,6 @@ export const dataAccess = () => ({ bedClientBuilder: ((callConfig: CallConfig) => new BedClient(callConfig)) as RestClientBuilder, assessmentClientBuilder: ((callConfig: CallConfig) => new AssessmentClient(callConfig)) as RestClientBuilder, - timelineClientBuilder: ((callConfig: CallConfig) => - new TimelineClient(callConfig)) as RestClientBuilder, }) export type DataAccess = ReturnType @@ -66,5 +63,4 @@ export { ReportClient, RestClientBuilder, UserClient, - TimelineClient, } diff --git a/server/data/timelineClient.test.ts b/server/data/timelineClient.test.ts deleted file mode 100644 index ae3eb9fc1..000000000 --- a/server/data/timelineClient.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import nock from 'nock' - -import { fakerEN_GB as faker } from '@faker-js/faker' -import config from '../config' -import paths from '../paths/api' -import TimelineClient from './timelineClient' - -import { CallConfig } from './restClient' -import { referralHistorySystemNoteFactory } from '../testutils/factories' - -describe('Timeline client', () => { - let fakeApprovedPremisesApi: nock.Scope - let timelineClient: TimelineClient - - const callConfig = { token: 'some-token' } as CallConfig - - beforeEach(() => { - config.apis.approvedPremises.url = 'http://localhost:8080' - fakeApprovedPremisesApi = nock(config.apis.approvedPremises.url) - timelineClient = new TimelineClient(callConfig) - }) - - afterEach(() => { - if (!nock.isDone()) { - nock.cleanAll() - throw new Error('Not all nock interceptors were used!') - } - nock.abortPendingRequests() - nock.cleanAll() - }) - - describe('fetch', () => { - it('returns timeline results', async () => { - const results = referralHistorySystemNoteFactory.build() - const payload = faker.string.uuid() - - fakeApprovedPremisesApi - .get(paths.assessments.timeline({ assessmentId: payload })) - .matchHeader('authorization', `Bearer ${callConfig.token}`) - .reply(201, results) - - const result = await timelineClient.fetch(payload) - - expect(result).toEqual(results) - expect(nock.isDone()).toBeTruthy() - }) - }) -}) diff --git a/server/data/timelineClient.ts b/server/data/timelineClient.ts deleted file mode 100644 index 627373c13..000000000 --- a/server/data/timelineClient.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ReferralHistoryNote, TemporaryAccommodationApplication } from '@approved-premises/api' -import config, { ApiConfig } from '../config' - -import RestClient, { CallConfig } from './restClient' -import paths from '../paths/api' - -export default class TimelineClient { - restClient: RestClient - - constructor(callConfig: CallConfig) { - this.restClient = new RestClient('timelineClient', config.apis.approvedPremises as ApiConfig, callConfig) - } - - async fetch(assessmentId: TemporaryAccommodationApplication['assessmentId']): Promise> { - return this.restClient.get({ - path: paths.assessments.timeline({ assessmentId }), - }) - } -} diff --git a/server/paths/api.ts b/server/paths/api.ts index 32c12e3d5..a577ae106 100644 --- a/server/paths/api.ts +++ b/server/paths/api.ts @@ -67,8 +67,6 @@ const oasysPath = personPath.path('oasys') const cas3Path = path('/cas3') const reportsCas3Path = cas3Path.path('reports') -const timelineCas3Path = cas3Path.path('timeline').path(':assessmentId') - const applyPaths = { applications: { show: singleApplicationPath, @@ -130,7 +128,6 @@ export default { create: clarificationNotePaths.notes, update: clarificationNotePaths.notes.path(':clarificationNoteId'), }, - timeline: timelineCas3Path, }, people: { risks: { diff --git a/server/services/assessments/timelineService.test.ts b/server/services/assessments/timelineService.test.ts deleted file mode 100644 index e9d2948bb..000000000 --- a/server/services/assessments/timelineService.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { fakerEN_GB as faker } from '@faker-js/faker' -import { CallConfig } from '../../data/restClient' -import { referralHistorySystemNoteFactory } from '../../testutils/factories' -import TimelineService from './timelineService' -import TimelineClient from '../../data/timelineClient' - -jest.mock('../../data/timelineClient.ts') - -describe('TimelineService', () => { - const timelineClient = new TimelineClient(null) as jest.Mocked - const timelineClientFactory = jest.fn() - - const service = new TimelineService(timelineClientFactory) - - beforeEach(() => { - jest.resetAllMocks() - timelineClientFactory.mockReturnValue(timelineClient) - }) - - describe('getTimelineForAssessment', () => { - it('on success returns the formated timeline data from after receiveing it from api', async () => { - const systemNote = referralHistorySystemNoteFactory.build({ category: 'submitted' }) - const assessmentId = faker.string.uuid() - - const callConfig = { token: 'some-token' } as CallConfig - - timelineClient.fetch.mockResolvedValue([systemNote]) - - const timelineData = await service.getTimelineForAssessment(callConfig, assessmentId) - expect(timelineData).toEqual([ - { - byline: { text: systemNote.createdByUserName }, - datetime: { timestamp: systemNote.createdAt, type: 'datetime' }, - html: undefined, - label: { text: 'Referral submitted' }, - }, - ]) - - expect(timelineClientFactory).toHaveBeenCalledWith(callConfig) - expect(timelineClient.fetch).toHaveBeenCalledWith(assessmentId) - }) - }) -}) diff --git a/server/services/assessments/timelineService.ts b/server/services/assessments/timelineService.ts deleted file mode 100644 index 3369fdff9..000000000 --- a/server/services/assessments/timelineService.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { TemporaryAccommodationApplication } from '@approved-premises/api' -import { TimelineItem } from '@approved-premises/ui' -import type { RestClientBuilder } from '../../data' -import { CallConfig } from '../../data/restClient' -import TimelineClient from '../../data/timelineClient' -import { timelineData } from '../../utils/assessments/timelineUtils' - -export default class TimelineService { - constructor(private readonly timelineClientFactory: RestClientBuilder) {} - - async getTimelineForAssessment( - callConfig: CallConfig, - assessmentId: TemporaryAccommodationApplication['assessmentId'], - ): Promise> { - const timelineClient = this.timelineClientFactory(callConfig) - - const rawTimelineData = await timelineClient.fetch(assessmentId) - - return timelineData(rawTimelineData) - } -} diff --git a/server/services/index.ts b/server/services/index.ts index 9f11bcfe0..77cafe163 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -20,7 +20,6 @@ import PersonService from './personService' import PremisesService from './premisesService' import ReportService from './reportService' import TurnaroundService from './turnaroundService' -import TimelineService from './assessments/timelineService' import UserService from './userService' import ReferenceDataService from './referenceDataService' @@ -37,7 +36,6 @@ export const services = () => { userClientBuilder, bedClientBuilder, assessmentClientBuilder, - timelineClientBuilder, } = dataAccess() const userService = new UserService(userClientBuilder) @@ -59,7 +57,6 @@ export const services = () => { const turnaroundService = new TurnaroundService(bookingClientBuilder) const assessmentsService = new AssessmentsService(assessmentClientBuilder, referenceDataClientBuilder) const referenceDataService = new ReferenceDataService(referenceDataClientBuilder) - const timelineService = new TimelineService(timelineClientBuilder) return { userService, @@ -81,7 +78,6 @@ export const services = () => { turnaroundService, assessmentsService, referenceDataService, - timelineService, } } @@ -103,5 +99,4 @@ export { TurnaroundService, UserService, ReferenceDataService, - TimelineService, } diff --git a/server/utils/assessmentUtils.test.ts b/server/utils/assessmentUtils.test.ts index ea6473fff..b10277d7e 100644 --- a/server/utils/assessmentUtils.test.ts +++ b/server/utils/assessmentUtils.test.ts @@ -1,4 +1,8 @@ import { AssessmentSearchApiStatus } from '@approved-premises/ui' +import { + ReferralHistoryDomainEventNote as DomainEventNote, + ReferralHistoryNoteMessageDetails, +} from '@approved-premises/api' import { fakerEN_GB as faker } from '@faker-js/faker' import paths from '../paths/temporary-accommodation/manage' import { @@ -23,9 +27,13 @@ import { insertUpdateDateError, pathFromStatus, referralRejectionReasonIsOther, + renderDomainEventNote, + renderNote, + renderSystemNote, statusChangeMessage, timelineItems, } from './assessmentUtils' +import * as assessmentUtils from './assessmentUtils' import * as viewUtils from './viewUtils' import { addPlaceContext, addPlaceContextFromAssessmentId, createPlaceContext } from './placeUtils' import { DateFormats } from './dateUtils' @@ -266,6 +274,148 @@ describe('assessmentUtils', () => { }) }) + describe('renderNote', () => { + it('renders the contents of a user note with paragraphs and line breaks', () => { + jest.spyOn(viewUtils, 'formatLines').mockReturnValue('formatted lines') + const note = referralHistoryUserNoteFactory.build({ + message: 'message contents', + }) + + const result = renderNote(note) + + expect(result).toEqual('formatted lines') + expect(viewUtils.formatLines).toHaveBeenCalledWith('message contents') + }) + + it('renders the contents of a system note with message details', () => { + jest.spyOn(assessmentUtils, 'renderSystemNote').mockReturnValue('formatted message') + const note = referralHistorySystemNoteFactory.build({ + message: '', + messageDetails: { + foo: 'bar', + } as ReferralHistoryNoteMessageDetails, + }) + + const result = renderNote(note) + + expect(result).toEqual('formatted message') + expect(assessmentUtils.renderSystemNote).toHaveBeenCalledWith(note) + }) + + it('renders the contents of a domain event note with details', () => { + jest.spyOn(assessmentUtils, 'renderDomainEventNote').mockReturnValue('formatted message') + const note: DomainEventNote = { + id: faker.string.uuid(), + createdByUserName: faker.person.fullName(), + createdAt: DateFormats.dateObjToIsoDate(faker.date.past()), + type: 'domainEvent', + message: '', + messageDetails: { + domainEvent: { foo: 'bar' }, + } as ReferralHistoryNoteMessageDetails, + } + + const result = renderNote(note) + + expect(result).toEqual('formatted message') + expect(assessmentUtils.renderDomainEventNote).toHaveBeenCalledWith(note.messageDetails) + }) + + it('returns undefined for a system note with no message details', () => { + const note = referralHistorySystemNoteFactory.build({ + message: '', + messageDetails: undefined, + }) + + expect(renderNote(note)).toBeUndefined() + }) + }) + + describe('renderSystemNote', () => { + describe('for a rejection note', () => { + it('returns HTML for a standard rejection reason', () => { + const note = referralHistorySystemNoteFactory.build({ + category: 'rejected', + message: '', + messageDetails: { + rejectionReason: 'A standard reason', + isWithdrawn: true, + }, + }) + + const result = renderSystemNote(note) + + expect(result).toEqual( + '

Rejection reason: A standard reason

Withdrawal requested by the probation practitioner: Yes

', + ) + }) + + it('returns HTML with user provided details for a another rejection reason', () => { + const note = referralHistorySystemNoteFactory.build({ + category: 'rejected', + message: '', + messageDetails: { + rejectionReason: 'Another reason (please add)', + rejectionReasonDetails: 'Some details', + isWithdrawn: false, + }, + }) + + const result = renderSystemNote(note) + + expect(result).toEqual( + '

Rejection reason: Some details

Withdrawal requested by the probation practitioner: No

', + ) + }) + }) + }) + + describe('renderDomainEventDetails', () => { + describe('when "Accommodation required from date" has been updated', () => { + it('returns HTML for a standard rejection reason', () => { + const messageDetails: DomainEventNote['messageDetails'] = { + domainEvent: { + eventType: 'accommodation.cas3.assessment.updated', + updatedFields: [ + { + fieldName: 'accommodationRequiredFromDate', + updatedTo: '2125-11-01', + updatedFrom: '2125-01-31', + }, + ], + }, + } + + const result = renderDomainEventNote(messageDetails) + + expect(result).toEqual( + '

Accommodation required from date was changed from 31 January 2125 to 1 November 2125

', + ) + }) + }) + + describe('when "Release date" has been updated', () => { + it('returns HTML for a standard rejection reason', () => { + const messageDetails: DomainEventNote['messageDetails'] = { + domainEvent: { + eventType: 'accommodation.cas3.assessment.updated', + updatedFields: [ + { + fieldName: 'releaseDate', + updatedTo: '2125-11-01', + updatedFrom: '2125-01-31', + }, + ], + }, + } + + const result = renderDomainEventNote(messageDetails) + + expect(result).toEqual('

Release date was changed from 31 January 2125 to 1 November 2125

') + }) + }) + }) + describe('timelineItems', () => { it('returns a notes in a format compatible with the MoJ timeline component', () => { const userNote1 = referralHistoryUserNoteFactory.build({ diff --git a/server/utils/assessmentUtils.ts b/server/utils/assessmentUtils.ts index fc5ab1c32..4f64b9edd 100644 --- a/server/utils/assessmentUtils.ts +++ b/server/utils/assessmentUtils.ts @@ -3,7 +3,10 @@ import { AssessmentSortField, TemporaryAccommodationAssessmentStatus as AssessmentStatus, TemporaryAccommodationAssessmentSummary as AssessmentSummary, + ReferralHistoryDomainEventNote as DomainEventNote, SortDirection, + ReferralHistorySystemNote as SystemNote, + ReferralHistoryUserNote as UserNote, } from '@approved-premises/api' import QueryString from 'qs' import { @@ -20,12 +23,12 @@ import paths from '../paths/temporary-accommodation/manage' import { DateFormats } from './dateUtils' import { personName } from './personUtils' import { addPlaceContext, addPlaceContextFromAssessmentId, createPlaceContext } from './placeUtils' -import { assertUnreachable } from './utils' +import { assertUnreachable, convertToTitleCase } from './utils' +import { formatLines } from './viewUtils' import { statusName, statusTag } from './assessmentStatusUtils' import { sortHeader } from './sortHeader' import { SanitisedError } from '../sanitisedError' import { insertBespokeError, insertGenericError } from './validation' -import { timelineData } from './assessments/timelineUtils' export const assessmentTableRows = (assessmentSummary: AssessmentSummary, showStatus: boolean = false): TableRow => { const row = [ @@ -133,8 +136,41 @@ export const assessmentActions = (assessment: Assessment) => { return items } +const timeLineLabelText = (note: UserNote | SystemNote | DomainEventNote): string => { + switch (note.type) { + case 'domainEvent': + return domainEventLabelText(note.messageDetails as DomainEventNote['messageDetails']) + case 'user': + return 'Note' + case 'system': + return systemNoteLabelText(note as SystemNote) + default: + throw new Error(`Unknown type of timeline item - ${note.type}`) + } +} + export const timelineItems = (assessment: Assessment): Array => { - return timelineData(assessment.referralHistoryNotes) + const notes = [...assessment.referralHistoryNotes].sort((noteA, noteB) => { + if (noteA.createdAt === noteB.createdAt) { + return 0 + } + + return noteA.createdAt < noteB.createdAt ? 1 : -1 + }) + + return notes.map(note => { + return { + label: { + text: timeLineLabelText(note), + }, + html: renderNote(note), + datetime: { + timestamp: note.createdAt, + type: 'datetime', + }, + byline: { text: convertToTitleCase(note.createdByUserName) }, + } + }) } export const referralRejectionReasonIsOther = ( @@ -177,6 +213,89 @@ export const statusChangeMessage = (assessmentId: string, status: AssessmentStat } } +const isDomainEventNote = (note: UserNote | SystemNote | DomainEventNote) => { + return Boolean(note.type === 'domainEvent') +} + +const isUserNote = (note: UserNote | SystemNote): note is UserNote => { + return Boolean(note.type === 'user') +} + +const isSystemNote = (note: UserNote | SystemNote): note is SystemNote => { + return Boolean(note.type === 'system') +} + +const isSystemNoteWithDetails = (note: UserNote | SystemNote): note is SystemNote => { + return Boolean(isSystemNote(note) && note.messageDetails) +} + +export const renderDomainEventNote = (note: DomainEventNote['messageDetails']): TimelineItem['html'] | never => { + const updatedField = note.domainEvent.updatedFields[0] + switch (updatedField.fieldName) { + case 'accommodationRequiredFromDate': + return `

Accommodation required from date was changed from ${DateFormats.isoDateToUIDate(updatedField.updatedFrom)} to ${DateFormats.isoDateToUIDate(updatedField.updatedTo)}

` + case 'releaseDate': + return `

Release date was changed from ${DateFormats.isoDateToUIDate(updatedField.updatedFrom)} to ${DateFormats.isoDateToUIDate(updatedField.updatedTo)}

` + default: + return assertUnreachable(updatedField.fieldName as never) + } +} + +export const renderSystemNote = (note: SystemNote): TimelineItem['html'] => { + const reason = note.messageDetails.rejectionReasonDetails || note.messageDetails.rejectionReason + const isWithdrawn = note.messageDetails.isWithdrawn ? 'Yes' : 'No' + + const lines = [`Rejection reason: ${reason}`, `Withdrawal requested by the probation practitioner: ${isWithdrawn}`] + + return formatLines(lines.join('\n\n')) +} + +export const renderNote = (note: UserNote | SystemNote | DomainEventNote): TimelineItem['html'] => { + if (isSystemNoteWithDetails(note)) { + return renderSystemNote(note) + } + + if (isUserNote(note)) { + return formatLines(note.message) + } + + if (isDomainEventNote(note)) { + return renderDomainEventNote((note as DomainEventNote).messageDetails) + } + + return undefined +} + +const systemNoteLabelText = (note: SystemNote): TimelineItem['label']['text'] => { + switch (note.category) { + case 'submitted': + return 'Referral submitted' + case 'unallocated': + return 'Referral marked as unallocated' + case 'in_review': + return 'Referral marked as in review' + case 'ready_to_place': + return 'Referral marked as ready to place' + case 'rejected': + return 'Referral marked as rejected' + case 'completed': + return 'Referral marked as closed' + default: + return assertUnreachable(note.category) + } +} + +const domainEventLabelText = (note: DomainEventNote['messageDetails']): TimelineItem['label']['text'] => { + switch (note.domainEvent.updatedFields[0].fieldName) { + case 'accommodationRequiredFromDate': + return 'Accommodation required from date updated' + case 'releaseDate': + return 'Release date updated' + default: + return assertUnreachable(note.domainEvent.updatedFields[0].fieldName as never) + } +} + export const createTableHeadings = ( currentSortBy: AssessmentSortField, sortIsAscending: boolean, diff --git a/server/utils/assessments/timelineUtils.test.ts b/server/utils/assessments/timelineUtils.test.ts deleted file mode 100644 index 17035f4da..000000000 --- a/server/utils/assessments/timelineUtils.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { fakerEN_GB as faker } from '@faker-js/faker' -import { - ReferralHistoryDomainEventNote as DomainEventNote, - ReferralHistoryNoteMessageDetails, -} from '@approved-premises/api' -import { - referralHistoryDomainEventNoteFactory, - referralHistorySystemNoteFactory, - referralHistoryUserNoteFactory, -} from '../../testutils/factories' -import { DateFormats } from '../dateUtils' -import * as viewUtils from '../viewUtils' -import * as timelineUtils from './timelineUtils' - -afterEach(() => { - jest.restoreAllMocks() -}) - -describe('timelineUtils', () => { - describe('timelineData', () => { - it('returns a notes in a format compatible with the MoJ timeline component', () => { - const userNote1 = referralHistoryUserNoteFactory.build({ - createdByUserName: 'SOME USER', - createdAt: '2024-04-01', - }) - const userNote2 = referralHistoryUserNoteFactory.build({ - createdByUserName: 'ANOTHER USER', - createdAt: '2024-05-01', - }) - const systemNote1 = referralHistorySystemNoteFactory.build({ - createdByUserName: 'SOME USER', - createdAt: '2024-04-02', - category: 'in_review', - }) - const systemNote2 = referralHistorySystemNoteFactory.build({ - createdByUserName: 'SOME USER', - createdAt: '2024-05-02', - category: 'ready_to_place', - }) - - const domainEventNote1 = referralHistoryDomainEventNoteFactory.build({ - createdByUserName: 'SOME USER', - createdAt: '2024-06-02', - messageDetails: { - domainEvent: { - eventType: 'accommodation.cas3.assessment.updated', - timestamp: DateFormats.dateObjToIsoDate(faker.date.past()), - updatedFields: [ - { - fieldName: 'accommodationRequiredFromDate', - updatedTo: '2025-09-02', - updatedFrom: '2123-09-02', - }, - ], - }, - }, - }) - - const domainEventNote2 = referralHistoryDomainEventNoteFactory.build({ - createdByUserName: 'SOME USER', - createdAt: '2024-06-01', - messageDetails: { - domainEvent: { - eventType: 'accommodation.cas3.assessment.updated', - timestamp: DateFormats.dateObjToIsoDate(faker.date.past()), - updatedFields: [ - { - fieldName: 'releaseDate', - updatedTo: '2025-09-02', - updatedFrom: '2123-09-02', - }, - ], - }, - }, - }) - - const notes = [systemNote1, systemNote2, userNote2, userNote1, domainEventNote1, domainEventNote2] - const userNoteHtml = 'some formatted html' - - jest.spyOn(viewUtils, 'formatLines').mockReturnValue(userNoteHtml) - const result = timelineUtils.timelineData(notes) - - expect(result).toEqual([ - { - label: { - text: 'Accommodation required from date updated', - }, - html: '

Accommodation required from date was changed from 2 September 2123 to 2 September 2025

', - datetime: { - timestamp: domainEventNote1.createdAt, - type: 'datetime', - }, - byline: { - text: 'Some User', - }, - }, - { - label: { - text: 'Release date updated', - }, - html: '

Release date was changed from 2 September 2123 to 2 September 2025

', - datetime: { - timestamp: domainEventNote2.createdAt, - type: 'datetime', - }, - byline: { - text: 'Some User', - }, - }, - { - label: { - text: 'Referral marked as ready to place', - }, - datetime: { - timestamp: systemNote2.createdAt, - type: 'datetime', - }, - byline: { - text: 'Some User', - }, - }, - { - label: { - text: 'Note', - }, - html: userNoteHtml, - datetime: { - timestamp: userNote2.createdAt, - type: 'datetime', - }, - byline: { - text: 'Another User', - }, - }, - { - label: { - text: 'Referral marked as in review', - }, - datetime: { - timestamp: systemNote1.createdAt, - type: 'datetime', - }, - byline: { - text: 'Some User', - }, - }, - { - label: { - text: 'Note', - }, - html: userNoteHtml, - datetime: { - timestamp: userNote1.createdAt, - type: 'datetime', - }, - byline: { - text: 'Some User', - }, - }, - ]) - }) - }) - - describe('renderNote', () => { - it('renders the contents of a user note with paragraphs and line breaks', () => { - jest.spyOn(viewUtils, 'formatLines').mockReturnValue('formatted lines') - const note = referralHistoryUserNoteFactory.build({ - message: 'message contents', - }) - - const result = timelineUtils.renderNote(note) - - expect(result).toEqual('formatted lines') - expect(viewUtils.formatLines).toHaveBeenCalledWith('message contents') - }) - - it('renders the contents of a system note with message details', () => { - jest.spyOn(timelineUtils, 'renderSystemNote').mockReturnValue('formatted message') - const note = referralHistorySystemNoteFactory.build({ - message: '', - messageDetails: { - foo: 'bar', - } as ReferralHistoryNoteMessageDetails, - }) - - const result = timelineUtils.renderNote(note) - - expect(result).toEqual('formatted message') - expect(timelineUtils.renderSystemNote).toHaveBeenCalledWith(note) - }) - - it('renders the contents of a domain event note with details', () => { - jest.spyOn(timelineUtils, 'renderDomainEventNote').mockReturnValue('formatted message') - const note: DomainEventNote = { - id: faker.string.uuid(), - createdByUserName: faker.person.fullName(), - createdAt: DateFormats.dateObjToIsoDate(faker.date.past()), - type: 'domainEvent', - message: '', - messageDetails: { - domainEvent: { foo: 'bar' }, - } as ReferralHistoryNoteMessageDetails, - } - - const result = timelineUtils.renderNote(note) - - expect(result).toEqual('formatted message') - expect(timelineUtils.renderDomainEventNote).toHaveBeenCalledWith(note.messageDetails) - }) - - it('returns undefined for a system note with no message details', () => { - const note = referralHistorySystemNoteFactory.build({ - message: '', - messageDetails: undefined, - }) - - expect(timelineUtils.renderNote(note)).toBeUndefined() - }) - }) - - describe('renderSystemNote', () => { - describe('for a rejection note', () => { - it('returns HTML for a standard rejection reason', () => { - const note = referralHistorySystemNoteFactory.build({ - category: 'rejected', - message: '', - messageDetails: { - rejectionReason: 'A standard reason', - isWithdrawn: true, - }, - }) - - const result = timelineUtils.renderSystemNote(note) - - expect(result).toEqual( - '

Rejection reason: A standard reason

Withdrawal requested by the probation practitioner: Yes

', - ) - }) - - it('returns HTML with user provided details for a another rejection reason', () => { - const note = referralHistorySystemNoteFactory.build({ - category: 'rejected', - message: '', - messageDetails: { - rejectionReason: 'Another reason (please add)', - rejectionReasonDetails: 'Some details', - isWithdrawn: false, - }, - }) - - const result = timelineUtils.renderSystemNote(note) - - expect(result).toEqual( - '

Rejection reason: Some details

Withdrawal requested by the probation practitioner: No

', - ) - }) - }) - }) - - describe('renderDomainEventDetails', () => { - describe('when "Accommodation required from date" has been updated', () => { - it('returns HTML for a standard rejection reason', () => { - const messageDetails: DomainEventNote['messageDetails'] = { - domainEvent: { - eventType: 'accommodation.cas3.assessment.updated', - updatedFields: [ - { - fieldName: 'accommodationRequiredFromDate', - updatedTo: '2125-11-01', - updatedFrom: '2125-01-31', - }, - ], - }, - } - - const result = timelineUtils.renderDomainEventNote(messageDetails) - - expect(result).toEqual( - '

Accommodation required from date was changed from 31 January 2125 to 1 November 2125

', - ) - }) - }) - - describe('when "Release date" has been updated', () => { - it('returns HTML for a standard rejection reason', () => { - const messageDetails: DomainEventNote['messageDetails'] = { - domainEvent: { - eventType: 'accommodation.cas3.assessment.updated', - updatedFields: [ - { - fieldName: 'releaseDate', - updatedTo: '2125-11-01', - updatedFrom: '2125-01-31', - }, - ], - }, - } - - const result = timelineUtils.renderDomainEventNote(messageDetails) - - expect(result).toEqual('

Release date was changed from 31 January 2125 to 1 November 2125

') - }) - }) - }) -}) diff --git a/server/utils/assessments/timelineUtils.ts b/server/utils/assessments/timelineUtils.ts deleted file mode 100644 index 7c46914da..000000000 --- a/server/utils/assessments/timelineUtils.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - ReferralHistoryDomainEventNote as DomainEventNote, - ReferralHistoryNote, - ReferralHistorySystemNote as SystemNote, - ReferralHistoryUserNote as UserNote, -} from '@approved-premises/api' -import { TimelineItem } from '@approved-premises/ui' -import { assertUnreachable, convertToTitleCase } from '../utils' - -import { formatLines } from '../viewUtils' -import { DateFormats } from '../dateUtils' - -export const timelineData = (events: Array): Array => { - const notes = [...events].sort((noteA, noteB) => { - if (noteA.createdAt === noteB.createdAt) { - return 0 - } - - return noteA.createdAt < noteB.createdAt ? 1 : -1 - }) - - return notes.map(note => { - return { - label: { - text: timeLineLabelText(note), - }, - html: renderNote(note), - datetime: { - timestamp: note.createdAt, - type: 'datetime', - }, - byline: { text: convertToTitleCase(note.createdByUserName) }, - } - }) -} - -const timeLineLabelText = (note: UserNote | SystemNote | DomainEventNote): string => { - switch (note.type) { - case 'domainEvent': - return domainEventLabelText(note.messageDetails as DomainEventNote['messageDetails']) - case 'user': - return 'Note' - case 'system': - return systemNoteLabelText(note as SystemNote) - default: - throw new Error(`Unknown type of timeline item - ${note.type}`) - } -} - -const domainEventLabelText = (note: DomainEventNote['messageDetails']): TimelineItem['label']['text'] => { - switch (note.domainEvent.updatedFields[0].fieldName) { - case 'accommodationRequiredFromDate': - return 'Accommodation required from date updated' - case 'releaseDate': - return 'Release date updated' - default: - return assertUnreachable(note.domainEvent.updatedFields[0].fieldName as never) - } -} - -const systemNoteLabelText = (note: SystemNote): TimelineItem['label']['text'] => { - switch (note.category) { - case 'submitted': - return 'Referral submitted' - case 'unallocated': - return 'Referral marked as unallocated' - case 'in_review': - return 'Referral marked as in review' - case 'ready_to_place': - return 'Referral marked as ready to place' - case 'rejected': - return 'Referral marked as rejected' - case 'completed': - return 'Referral marked as closed' - default: - return assertUnreachable(note.category) - } -} - -export const renderNote = (note: UserNote | SystemNote | DomainEventNote): TimelineItem['html'] => { - if (isSystemNoteWithDetails(note)) { - return renderSystemNote(note) - } - - if (isUserNote(note)) { - return formatLines(note.message) - } - - if (isDomainEventNote(note)) { - return renderDomainEventNote((note as DomainEventNote).messageDetails) - } - - return undefined -} - -export const renderDomainEventNote = (note: DomainEventNote['messageDetails']): TimelineItem['html'] | never => { - const updatedField = note.domainEvent.updatedFields[0] - switch (updatedField.fieldName) { - case 'accommodationRequiredFromDate': - return `

Accommodation required from date was changed from ${DateFormats.isoDateToUIDate(updatedField.updatedFrom)} to ${DateFormats.isoDateToUIDate(updatedField.updatedTo)}

` - case 'releaseDate': - return `

Release date was changed from ${DateFormats.isoDateToUIDate(updatedField.updatedFrom)} to ${DateFormats.isoDateToUIDate(updatedField.updatedTo)}

` - default: - return assertUnreachable(updatedField.fieldName as never) - } -} - -export const renderSystemNote = (note: SystemNote): TimelineItem['html'] => { - const reason = note.messageDetails.rejectionReasonDetails || note.messageDetails.rejectionReason - const isWithdrawn = note.messageDetails.isWithdrawn ? 'Yes' : 'No' - - const lines = [`Rejection reason: ${reason}`, `Withdrawal requested by the probation practitioner: ${isWithdrawn}`] - - return formatLines(lines.join('\n\n')) -} - -const isDomainEventNote = (note: UserNote | SystemNote | DomainEventNote) => { - return Boolean(note.type === 'domainEvent') -} - -const isUserNote = (note: UserNote | SystemNote): note is UserNote => { - return Boolean(note.type === 'user') -} - -const isSystemNote = (note: UserNote | SystemNote): note is SystemNote => { - return Boolean(note.type === 'system') -} - -const isSystemNoteWithDetails = (note: UserNote | SystemNote): note is SystemNote => { - return Boolean(isSystemNote(note) && note.messageDetails) -} diff --git a/server/views/applications/full.njk b/server/views/applications/full.njk index 9191850cf..49b2afee0 100644 --- a/server/views/applications/full.njk +++ b/server/views/applications/full.njk @@ -1,7 +1,6 @@ {%- from "moj/components/identity-bar/macro.njk" import mojIdentityBar -%} {% from "govuk/components/back-link/macro.njk" import govukBackLink %} {%- from "govuk/components/summary-list/macro.njk" import govukSummaryList -%} -{%- from "moj/components/timeline/macro.njk" import mojTimeline -%} {% from "../components/printButton/macro.njk" import printButtonScript, printButton %} {% extends "../partials/layout.njk" %} {% set pageTitle = "The person's referral - " + applicationName %} @@ -51,11 +50,6 @@ {% endfor %} {% endfor %} - -
-

Referral history

- {{ mojTimeline({ items: timelineData }) }} -
{% endblock %}