Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
1d67a91
Update button content to authorise
rivalee Aug 10, 2026
ac1c972
add line of text with mammographer name
rivalee Aug 10, 2026
4d18182
fix bug
rivalee Aug 12, 2026
f959888
Update content when breast implants added
rivalee Aug 18, 2026
dd44623
Update IRMER auth button to choose authoriser
rivalee Aug 20, 2026
46f6399
Update app/assets/javascript/expandable-sections.js
rivalee Sep 8, 2026
bf1c588
Update app/assets/javascript/expandable-sections.js
rivalee Sep 8, 2026
853fa0a
Update app/assets/javascript/expandable-sections.js
rivalee Sep 8, 2026
d98dd93
Update tests/e2e/appointment.spec.js
rivalee Sep 8, 2026
6827f6c
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
c4f26a2
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
4c40d50
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
f7ed4a8
Update tests/e2e/appointment.spec.js
rivalee Sep 8, 2026
a216bcb
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
378f66d
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
1b6ff3c
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
96909be
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
b7baa8c
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
4a1bfcf
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
d260ca0
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
0d225cb
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
d6650df
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
c4ab3e5
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
60ecf30
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
6c929ab
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
63e01eb
Update content
rivalee Sep 8, 2026
d61b226
edits
rivalee Sep 10, 2026
d22db11
update content
rivalee Sep 10, 2026
2c0bf9d
Update button content to authorise
rivalee Aug 10, 2026
6dc395d
add line of text with mammographer name
rivalee Aug 10, 2026
8b38b98
fix bug
rivalee Aug 12, 2026
ea70647
Update content when breast implants added
rivalee Aug 18, 2026
62fcccd
Update IRMER auth button to choose authoriser
rivalee Aug 20, 2026
03b3106
Update app/assets/javascript/expandable-sections.js
rivalee Sep 8, 2026
b6cbcf0
Update app/assets/javascript/expandable-sections.js
rivalee Sep 8, 2026
bf0b2ac
Update app/assets/javascript/expandable-sections.js
rivalee Sep 8, 2026
685decc
Update tests/e2e/appointment.spec.js
rivalee Sep 8, 2026
13aa879
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
63b8753
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
09b6efe
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
ada64e4
Update tests/e2e/appointment.spec.js
rivalee Sep 8, 2026
7478ec7
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
1ecca17
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
8a2dd2a
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
1f581e8
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
9d03405
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
8c20829
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
bb83d6a
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
4901a0f
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
0aaa5b6
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
c56a997
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
80edd4b
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
b9b249a
Update app/views/appointments/review-medical-information.html
rivalee Sep 8, 2026
abe0263
Update content
rivalee Sep 8, 2026
8b7d0ea
edits
rivalee Sep 10, 2026
147ac65
update content
rivalee Sep 10, 2026
d903c81
update content
rivalee Sep 14, 2026
664a9ae
fix merge conflicts hopefully
rivalee Sep 15, 2026
addd899
get success messages working, content edits
rivalee Sep 15, 2026
399d07e
make it possible to switch back to me
rivalee Sep 15, 2026
37dc4da
final tweaks
rivalee Sep 16, 2026
d432635
Rework authorised mammographer nomination to use permissions and user…
edwardhorsford Sep 16, 2026
a4ac1c5
Sort implant imaging mammographers by surname
edwardhorsford Sep 16, 2026
5b7977a
List every clinician able to screen the appointment, and show permiss…
edwardhorsford Sep 16, 2026
e4e663a
Use authorised mammographer consistently in code, data keys and routes
edwardhorsford Sep 16, 2026
4bd41d0
change formatting of name
rivalee Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/filters/nunjucks.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ const getUsername = function (userId, options = {}) {
const user = users.find((u) => u.id === userId)
if (!user) return userId

// Format options: full (default), short (initial + surname), initial (just initials)
// Format options: full (default), short (initial + surname), initial (just initials), reversed (SURNAME, First)
const format = options.format || 'full'

let formattedName
Expand All @@ -121,6 +121,9 @@ const getUsername = function (userId, options = {}) {
case 'initial':
formattedName = `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`
break
case 'reversed':
formattedName = `${user.lastName.toUpperCase()}, ${user.firstName}`
break
case 'full':
default:
formattedName = `${user.firstName} ${user.lastName}`
Expand Down
86 changes: 75 additions & 11 deletions app/lib/utils/roles-and-permissions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// app/lib/utils/roles-and-permissions.js

const { isMedicalHistoryItemRemoved } = require('./medical-information')
const { getUsername } = require('../../filters/nunjucks')

// Implanted devices that need a mammographer with implant imaging training.
// The prototype treats every device type as needing it - narrow this list if
Expand Down Expand Up @@ -79,17 +80,9 @@ const hasAnyPermission = (user, permissions) => {
return permissions.some((permission) => hasPermission(user, permission))
}

/**
* Check whether an appointment needs a user with implant imaging training
*
* Breast implants and implanted medical devices both need it, but only while
* they are still in place - once removed, any clinician can image the
* participant.
*
* @param {object} appointment - Appointment object
* @returns {boolean} Whether implant imaging training is needed
*/
const requiresImplantImaging = (appointment) => {
// Breakdown of what on an appointment needs implant imaging training. Kept
// unexported so it doesn't become a Nunjucks filter.
const getImplantImagingNeeds = (appointment) => {
const medicalHistory = appointment?.medicalInformation?.medicalHistory || {}

const hasActiveBreastImplants = (
Expand All @@ -104,9 +97,77 @@ const requiresImplantImaging = (appointment) => {
}
)

return { hasActiveBreastImplants, hasActiveDevice }
}

/**
* Check whether an appointment needs a user with implant imaging training
*
* Breast implants and implanted medical devices both need it, but only while
* they are still in place - once removed, any clinician can image the
* participant.
*
* @param {object} appointment - Appointment object
* @returns {boolean} Whether implant imaging training is needed
*/
const requiresImplantImaging = (appointment) => {
const { hasActiveBreastImplants, hasActiveDevice } =
getImplantImagingNeeds(appointment)

return hasActiveBreastImplants || hasActiveDevice
}

/**
* Describe why an appointment needs a mammographer with implant imaging
* training, for use in page content
*
* @param {object} appointment - Appointment object
* @returns {string} Reason text, or '' when implant imaging is not needed
*/
const getImplantImagingReason = (appointment) => {
const { hasActiveBreastImplants, hasActiveDevice } =
getImplantImagingNeeds(appointment)

if (hasActiveBreastImplants && hasActiveDevice) {
return 'breast implants and an implanted medical device'
}
if (hasActiveBreastImplants) return 'breast implants'
if (hasActiveDevice) return 'an implanted medical device'
return ''
}

/**
* Check whether an authorised mammographer other than the current user has
* been nominated to take the images for an appointment
*
* @param {object} appointment - Appointment object
* @returns {boolean} Whether an authorised mammographer has been nominated
*/
const hasNominatedAuthorisedMammographer = (appointment) => {
return Boolean(
appointment?.authorisedMammographerId ||
appointment?.authorisedMammographerOtherName
)
}

/**
* Get the display name of the authorised mammographer nominated for an
* appointment
*
* Returns '' when nobody has been nominated, so callers can decide their own
* fallback.
*
* @param {object} appointment - Appointment object
* @param {object} [options] - Display options, as accepted by getUsername
* @returns {string} Nominated authorised mammographer's name, or ''
*/
const getAuthorisedMammographerName = function (appointment, options = {}) {
if (appointment?.authorisedMammographerId) {
return getUsername.call(this, appointment.authorisedMammographerId, options)
}
return appointment?.authorisedMammographerOtherName || ''
}

/**
* Check whether a user is able to screen a given appointment
*
Expand Down Expand Up @@ -208,6 +269,9 @@ module.exports = {
hasPermission,
hasAnyPermission,
requiresImplantImaging,
getImplantImagingReason,
hasNominatedAuthorisedMammographer,
getAuthorisedMammographerName,
canUserScreenAppointment,
isClinician,
isAdministrative,
Expand Down
88 changes: 88 additions & 0 deletions app/routes/appointments/lifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,94 @@ module.exports = (router) => {
}
)

// Change the authorised mammographer from the in-page modal, then return to
// the page it was opened from with a success banner if it actually changed
router.post(
'/clinics/:clinicId/appointments/:appointmentId/change-authorised-mammographer-answer',
(req, res) => {
const { clinicId, appointmentId } = req.params
const data = req.session.data
const appointmentUrl = `/clinics/${clinicId}/appointments/${appointmentId}`

const authorisedMammographerTemp =
data.appointment?.authorisedMammographerTemp || {}
const selectedUserId = authorisedMammographerTemp.userId
const otherName = (authorisedMammographerTemp.otherName || '')
.toString()
.trim()

const errors = []
if (!selectedUserId) {
errors.push({
text: 'Select who is taking the images',
name: 'appointment[authorisedMammographerTemp][userId]',
href: '#authorisedMammographerUserId'
})
} else if (selectedUserId === 'other' && !otherName) {
errors.push({
text: "Enter the mammographer's full name",
name: 'appointment[authorisedMammographerTemp][otherName]',
href: '#authorisedMammographerOtherName'
})
}

if (errors.length) {
errors.forEach((error) => req.flash('error', error))
// Back to the modal page - _modal is threaded by the modal middleware,
// the referrer chain has to be carried by hand
return res.redirect(
urlWithReferrer(
`${appointmentUrl}/change-authorised-mammographer`,
req.query.referrerChain
)
)
}

const previousAuthorisedMammographerId =
data.appointment?.authorisedMammographerId
const previousAuthorisedMammographerOtherName =
data.appointment?.authorisedMammographerOtherName

if (selectedUserId === 'other') {
data.appointment.authorisedMammographerId = null
data.appointment.authorisedMammographerOtherName = otherName
} else {
// Nominating yourself is the same as no nomination at all
data.appointment.authorisedMammographerId =
selectedUserId === data.currentUser?.id ? null : selectedUserId
data.appointment.authorisedMammographerOtherName = null
}

// Clear the transient modal fields so they don't leak into other forms
delete data.appointment.authorisedMammographerTemp

const hasChanged =
data.appointment.authorisedMammographerId !==
(previousAuthorisedMammographerId || null) ||
data.appointment.authorisedMammographerOtherName !==
(previousAuthorisedMammographerOtherName || null)

if (hasChanged) {
const selectedUser = (data.users || []).find(
(user) => user.id === selectedUserId
)
const newAuthorisedMammographerName = selectedUser
? `${selectedUser.lastName.toUpperCase()}, ${selectedUser.firstName}`
: otherName
req.flash(
'success',
`Authorised mammographer updated to ${newAuthorisedMammographerName}`
)
}

const returnUrl = getReturnUrl(
`${appointmentUrl}/check-information`,
req.query.referrerChain
)
res.redirect(modalBreakout(returnUrl))
}
)

// Appointment within clinic context
router.get('/clinics/:clinicId/appointments/:appointmentId', (req, res) => {
const { clinicId, appointmentId } = req.params
Expand Down
3 changes: 3 additions & 0 deletions app/routes/clinics.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ module.exports = (router) => {
}

const clinicData = getClinicData(req.session.data, req.params.id)
if (!clinicData) {
return res.redirect('/clinics')
}
let remainingCount = filterAppointmentsByStatus(
clinicData.appointments,
'remaining'
Expand Down
56 changes: 56 additions & 0 deletions app/views/_includes/authorised-mammographer.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{# app/views/_includes/authorised-mammographer.njk #}

{#
The authorised mammographer for an in-progress appointment, alongside the
button that continues the workflow.

Where the current user cannot image this participant themselves and nobody
else has been nominated, the workflow is blocked until an authorised
mammographer with implant imaging training is chosen.

Set continueButtonText before including.
#}

{% set authorisedMammographerChangeHref = (appointmentUrl + "/change-authorised-mammographer") | urlWithReferrer(referrerChain | appendReferrer(currentUrl)) %}

{% set needsNominatedAuthorisedMammographer = appointment.workflowStatus['review-medical-information'] != 'completed'
and not (currentUser | canUserScreenAppointment(appointment))
and not (appointment | hasNominatedAuthorisedMammographer) %}

{% if needsNominatedAuthorisedMammographer %}

{% set insetTextHtml %}
<p>Due to {{ appointment | getImplantImagingReason }} you do not have the relevant permissions to take all the mammograms required for this appointment. Change the mammographer to someone authorised to take these images or {{ appLink({ text: "exit the appointment", href: (appointmentUrl + "/exit-appointment") | urlWithReferrer(referrerChain) }) | trim | safe }}.</p>
{% endset %}

{{ insetText({
html: insetTextHtml
}) }}

<div class="nhsuk-grid-row">
<div class="nhsuk-grid-column-two-thirds">
<div class="nhsuk-button-group">
{{ button({
text: "Change mammographer",
href: authorisedMammographerChangeHref,
classes: "nhsuk-u-margin-bottom-0"
} | openInModal) }}
</div>
</div>
</div>

{% else %}

{% set authorisedMammographerName = (appointment | getAuthorisedMammographerName({ format: "reversed" })) or (currentUser.id | getUsername({ format: "reversed" })) %}

<div class="nhsuk-grid-row">
<div class="nhsuk-grid-column-two-thirds">
{{ button({
text: continueButtonText,
classes: "nhsuk-u-margin-bottom-0 js-complete-all-sections"
}) }}
<p class="nhsuk-u-margin-top-2">Authorised mammographer: <span class="nhsuk-u-font-weight-bold">{{ authorisedMammographerName }}</span> ({{ appLink({ text: "change", href: authorisedMammographerChangeHref } | openInModal) | trim | safe }})</p>
</div>
</div>

{% endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@
}) %}
{% endif %}

{# Machine room #}
{# Mammogram location #}
{% if appointment.mammogramData.machineRoom %}
{# Check if this is a mobile clinic #}
{% set isMobileClinic = (clinic.location.type == 'mobile_unit') %}
Expand All @@ -148,14 +148,14 @@
{
href: "./images-room-selection" | urlWithReferrer(referrerChain, scrollTo),
text: "Change",
visuallyHiddenText: "machine room"
visuallyHiddenText: "mammogram location"
}
] %}
{% endif %}

{% set summaryRows = summaryRows | push({
key: {
text: "Machine room"
text: "Mammogram location"
},
value: {
text: appointment.mammogramData.machineRoom
Expand All @@ -166,6 +166,42 @@
}) %}
{% endif %}

{# Authorised mammographer - whoever was nominated to take the images, falling
back to whoever ran the appointment #}
{% set authorisedMammographerName = appointment | getAuthorisedMammographerName({ format: "reversed" }) %}
{% if not authorisedMammographerName %}
{% if appointment.sessionDetails.endedBy %}
{% set authorisedMammographerName = appointment.sessionDetails.endedBy | getUsername({ format: "reversed" }) %}
{% else %}
{% set authorisedMammographerName = appointment.sessionDetails.startedBy | getUsername({ format: "reversed" }) %}
{% endif %}
{% endif %}

{% if authorisedMammographerName %}
{% set authorisedMammographerActions = [] %}
{% if allowEdits and authorisedMammographerChangeHref %}
{% set authorisedMammographerActions = [
({
href: authorisedMammographerChangeHref,
text: "Change",
visuallyHiddenText: "authorised mammographer"
} | openInModal)
] %}
{% endif %}

{% set summaryRows = summaryRows | push({
key: {
text: "Authorised mammographer"
},
value: {
text: authorisedMammographerName
},
actions: {
items: authorisedMammographerActions
}
}) %}
{% endif %}

{# Combined views taken (showing all view information in one row) #}
{# Sort views by side and standard order before displaying #}
{% set sortedViews = [] %}
Expand Down
17 changes: 14 additions & 3 deletions app/views/appointments/appointment.html
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,11 @@
},
{
key: {
text: "Screened by"
text: "Run by"
},
value: {
html: (appointment.sessionDetails.endedBy | getUsername) +
((" and " + (appointment.sessionDetails.authors | last).userId | getUsername)
html: (appointment.sessionDetails.endedBy | getUsername({ format: "reversed" })) +
((" and " + ((appointment.sessionDetails.authors | last).userId | getUsername({ format: "reversed" })))
if appointment.sessionDetails.authors and appointment.sessionDetails.authors.length > 0
and (appointment.sessionDetails.authors | last).userId != appointment.sessionDetails.endedBy
else "")
Expand All @@ -242,6 +242,17 @@
items: []
}
} if appointment.status == "complete" and appointment.sessionDetails.endedBy else {},
{
key: {
text: "Authorised mammographer"
},
value: {
html: (appointment | getAuthorisedMammographerName({ format: "reversed" })) or (appointment.sessionDetails.endedBy | getUsername({ format: "reversed" })) or (appointment.sessionDetails.startedBy | getUsername({ format: "reversed" }))
},
actions: {
items: []
}
} if appointment.status == "complete" and appointment.sessionDetails.endedBy else {},
{
key: {
text: "Special appointment"
Expand Down
Loading