From 94de5ef1b8c5fbf2dcf5dceb0962b7c33cca14f1 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 20 Nov 2025 13:14:35 +0000 Subject: [PATCH 01/41] feat: feedback component and scss --- .../src/Web/app/components/feedback.tsx | 40 +++++++++++++++++++ .../src/Web/app/exceptions/page.tsx | 2 + .../CohortManager/src/Web/app/globals.scss | 21 ++++++++++ 3 files changed, 63 insertions(+) create mode 100644 application/CohortManager/src/Web/app/components/feedback.tsx diff --git a/application/CohortManager/src/Web/app/components/feedback.tsx b/application/CohortManager/src/Web/app/components/feedback.tsx new file mode 100644 index 0000000000..e0150ec15f --- /dev/null +++ b/application/CohortManager/src/Web/app/components/feedback.tsx @@ -0,0 +1,40 @@ +const FeedbackComponent = () => { + return ( +
+
+ +

Help us improve

+ +

Your feedback helps us make our service better.

+ + + + + Let us know about your experience of Cohort Manager + + + +

+ If you need technical support, please continue to use our{' '} + contact us page rather than this form. +

+
+ ); +}; + +export default FeedbackComponent; diff --git a/application/CohortManager/src/Web/app/exceptions/page.tsx b/application/CohortManager/src/Web/app/exceptions/page.tsx index 3bc51c4c4d..1f79a27a1f 100644 --- a/application/CohortManager/src/Web/app/exceptions/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/page.tsx @@ -10,6 +10,7 @@ import Breadcrumb from "@/app/components/breadcrumb"; import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; import Pagination from "@/app/components/pagination"; +import FeedbackComponent from "@/app/components/feedback"; export const metadata: Metadata = { title: `Not raised breast screening exceptions - ${process.env.SERVICE_NAME} - NHS`, @@ -145,6 +146,7 @@ export default async function Page({ } /> )} + )} diff --git a/application/CohortManager/src/Web/app/globals.scss b/application/CohortManager/src/Web/app/globals.scss index 17c6297c31..39bb5f8f72 100644 --- a/application/CohortManager/src/Web/app/globals.scss +++ b/application/CohortManager/src/Web/app/globals.scss @@ -142,3 +142,24 @@ margin-right: 0; margin-left: 8px; } + +.nhsuk-action-link { + display: flex; + align-items: center; +} + +.nhsuk-icon--arrow-right-circle { + margin-right: 8px; +} + +.nhsuk-action-link__text { + font-size: 20px; + font-weight: bold; + align-self: center; +} + +.app-feedback-section .nhsuk-action-link svg.nhsuk-icon--arrow-right-circle { + fill: #00703c; + color: #00703c; +} + From e90338516e0cee20dbf61c0d01ccfc30908026e7 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 20 Nov 2025 14:04:05 +0000 Subject: [PATCH 02/41] feat: renamed component to UserFeedback and added components to required pages --- .../CohortManager/src/Web/app/components/feedback.tsx | 7 ++----- .../CohortManager/src/Web/app/exceptions/[filter]/page.tsx | 2 ++ application/CohortManager/src/Web/app/exceptions/page.tsx | 4 ++-- .../Web/app/participant-information/[exceptionId]/page.tsx | 2 ++ application/CohortManager/src/Web/app/reports/page.tsx | 2 ++ 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/application/CohortManager/src/Web/app/components/feedback.tsx b/application/CohortManager/src/Web/app/components/feedback.tsx index e0150ec15f..c70618671a 100644 --- a/application/CohortManager/src/Web/app/components/feedback.tsx +++ b/application/CohortManager/src/Web/app/components/feedback.tsx @@ -1,12 +1,10 @@ -const FeedbackComponent = () => { +const UserFeedback = () => { return (

Help us improve

-

Your feedback helps us make our service better.

- { Let us know about your experience of Cohort Manager -

If you need technical support, please continue to use our{' '} contact us page rather than this form. @@ -37,4 +34,4 @@ const FeedbackComponent = () => { ); }; -export default FeedbackComponent; +export default UserFeedback; diff --git a/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx b/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx index a06631a19d..26132338a4 100644 --- a/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx @@ -10,6 +10,7 @@ import Breadcrumb from "@/app/components/breadcrumb"; import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; import Pagination from "@/app/components/pagination"; +import UserFeedback from "@/app/components/feedback"; export const metadata: Metadata = { title: `Raised breast screening exceptions - ${process.env.SERVICE_NAME} - NHS`, @@ -144,6 +145,7 @@ export default async function Page({ } /> )} + )}

diff --git a/application/CohortManager/src/Web/app/exceptions/page.tsx b/application/CohortManager/src/Web/app/exceptions/page.tsx index 1f79a27a1f..40e8005182 100644 --- a/application/CohortManager/src/Web/app/exceptions/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/page.tsx @@ -10,7 +10,7 @@ import Breadcrumb from "@/app/components/breadcrumb"; import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; import Pagination from "@/app/components/pagination"; -import FeedbackComponent from "@/app/components/feedback"; +import UserFeedback from "@/app/components/feedback"; export const metadata: Metadata = { title: `Not raised breast screening exceptions - ${process.env.SERVICE_NAME} - NHS`, @@ -146,7 +146,7 @@ export default async function Page({ } /> )} - + )} diff --git a/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx b/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx index 25318a3768..9f3ad853f7 100644 --- a/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx +++ b/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx @@ -9,6 +9,7 @@ import Breadcrumb from "@/app/components/breadcrumb"; import ParticipantInformationPanel from "@/app/components/participantInformationPanel"; import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; +import UserFeedback from "@/app/components/feedback"; export const metadata: Metadata = { title: `Exception information - ${process.env.SERVICE_NAME} - NHS`, @@ -195,6 +196,7 @@ export default async function Page(props: { searchParams={resolvedSearchParams} /> + diff --git a/application/CohortManager/src/Web/app/reports/page.tsx b/application/CohortManager/src/Web/app/reports/page.tsx index 7fb5c9962e..5d958c5fd4 100644 --- a/application/CohortManager/src/Web/app/reports/page.tsx +++ b/application/CohortManager/src/Web/app/reports/page.tsx @@ -7,6 +7,7 @@ import ReportsTable from "@/app/components/reportsTable"; import Unauthorised from "@/app/components/unauthorised"; import { type ReportDetails } from "@/app/types"; import { formatDate, formatIsoDate } from "../lib/utils"; +import UserFeedback from "@/app/components/feedback"; export const metadata: Metadata = { title: `Reports - ${process.env.SERVICE_NAME} - NHS`, @@ -75,6 +76,7 @@ export default async function Page() { + ); From a04cebdec010ca2e7e4ab5266ed3a490c879dda8 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 20 Nov 2025 14:05:19 +0000 Subject: [PATCH 03/41] chore: renamed imports from feedback to UserFeedback --- .../src/Web/app/components/{feedback.tsx => userFeedback.tsx} | 0 .../CohortManager/src/Web/app/exceptions/[filter]/page.tsx | 2 +- application/CohortManager/src/Web/app/exceptions/page.tsx | 2 +- .../src/Web/app/participant-information/[exceptionId]/page.tsx | 2 +- application/CohortManager/src/Web/app/reports/page.tsx | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename application/CohortManager/src/Web/app/components/{feedback.tsx => userFeedback.tsx} (100%) diff --git a/application/CohortManager/src/Web/app/components/feedback.tsx b/application/CohortManager/src/Web/app/components/userFeedback.tsx similarity index 100% rename from application/CohortManager/src/Web/app/components/feedback.tsx rename to application/CohortManager/src/Web/app/components/userFeedback.tsx diff --git a/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx b/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx index 26132338a4..52ddc7cec0 100644 --- a/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/[filter]/page.tsx @@ -10,7 +10,7 @@ import Breadcrumb from "@/app/components/breadcrumb"; import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; import Pagination from "@/app/components/pagination"; -import UserFeedback from "@/app/components/feedback"; +import UserFeedback from "@/app/components/userFeedback"; export const metadata: Metadata = { title: `Raised breast screening exceptions - ${process.env.SERVICE_NAME} - NHS`, diff --git a/application/CohortManager/src/Web/app/exceptions/page.tsx b/application/CohortManager/src/Web/app/exceptions/page.tsx index 40e8005182..70cae23305 100644 --- a/application/CohortManager/src/Web/app/exceptions/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/page.tsx @@ -10,7 +10,7 @@ import Breadcrumb from "@/app/components/breadcrumb"; import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; import Pagination from "@/app/components/pagination"; -import UserFeedback from "@/app/components/feedback"; +import UserFeedback from "@/app/components/userFeedback"; export const metadata: Metadata = { title: `Not raised breast screening exceptions - ${process.env.SERVICE_NAME} - NHS`, diff --git a/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx b/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx index 9f3ad853f7..82465fed47 100644 --- a/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx +++ b/application/CohortManager/src/Web/app/participant-information/[exceptionId]/page.tsx @@ -9,7 +9,7 @@ import Breadcrumb from "@/app/components/breadcrumb"; import ParticipantInformationPanel from "@/app/components/participantInformationPanel"; import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; -import UserFeedback from "@/app/components/feedback"; +import UserFeedback from "@/app/components/userFeedback"; export const metadata: Metadata = { title: `Exception information - ${process.env.SERVICE_NAME} - NHS`, diff --git a/application/CohortManager/src/Web/app/reports/page.tsx b/application/CohortManager/src/Web/app/reports/page.tsx index 5d958c5fd4..fcbe754310 100644 --- a/application/CohortManager/src/Web/app/reports/page.tsx +++ b/application/CohortManager/src/Web/app/reports/page.tsx @@ -7,7 +7,7 @@ import ReportsTable from "@/app/components/reportsTable"; import Unauthorised from "@/app/components/unauthorised"; import { type ReportDetails } from "@/app/types"; import { formatDate, formatIsoDate } from "../lib/utils"; -import UserFeedback from "@/app/components/feedback"; +import UserFeedback from "@/app/components/userFeedback"; export const metadata: Metadata = { title: `Reports - ${process.env.SERVICE_NAME} - NHS`, From e515277d89509f9759a691cecb86d95850687536 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 20 Nov 2025 15:14:48 +0000 Subject: [PATCH 04/41] feat: unused css removed --- application/CohortManager/src/Web/app/globals.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/application/CohortManager/src/Web/app/globals.scss b/application/CohortManager/src/Web/app/globals.scss index 39bb5f8f72..6b9cb0f77c 100644 --- a/application/CohortManager/src/Web/app/globals.scss +++ b/application/CohortManager/src/Web/app/globals.scss @@ -145,7 +145,6 @@ .nhsuk-action-link { display: flex; - align-items: center; } .nhsuk-icon--arrow-right-circle { From 2e8f20703f5bf6bce1d2e56c535087a6b3b55164 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 20 Nov 2025 15:48:17 +0000 Subject: [PATCH 05/41] feat: input added to UI --- .../CohortManager/src/Web/app/components/header.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/application/CohortManager/src/Web/app/components/header.tsx b/application/CohortManager/src/Web/app/components/header.tsx index f6af3d909c..94982eb4db 100644 --- a/application/CohortManager/src/Web/app/components/header.tsx +++ b/application/CohortManager/src/Web/app/components/header.tsx @@ -46,6 +46,16 @@ export default async function Header({ data-testid="header-account-navigation" >
    +
  • + +
  • Date: Thu, 27 Nov 2025 11:31:23 +0000 Subject: [PATCH 06/41] feat: search page --- .../src/Web/app/exceptions/search/page.tsx | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 application/CohortManager/src/Web/app/exceptions/search/page.tsx diff --git a/application/CohortManager/src/Web/app/exceptions/search/page.tsx b/application/CohortManager/src/Web/app/exceptions/search/page.tsx new file mode 100644 index 0000000000..4442de343d --- /dev/null +++ b/application/CohortManager/src/Web/app/exceptions/search/page.tsx @@ -0,0 +1,275 @@ +import type { Metadata } from "next"; +import { auth } from "@/app/lib/auth"; +import { canAccessCohortManager } from "@/app/lib/access"; +import { fetchExceptionsByNhsNumber } from "@/app/lib/fetchExceptions"; +import { getRuleMapping } from "@/app/lib/ruleMapping"; +import ExceptionsTable from "@/app/components/exceptionsTable"; +import Breadcrumb from "@/app/components/breadcrumb"; +import Unauthorised from "@/app/components/unauthorised"; +import { ExceptionDetails } from "@/app/types"; + +export const metadata: Metadata = { + title: `Search exceptions by NHS number - ${process.env.SERVICE_NAME} - NHS`, +}; + +interface ApiException { + ExceptionId: number; + NhsNumber: string; + DateCreated: string; + RuleId: number; + RuleDescription: string; + ServiceNowId: string | null; + ServiceNowCreatedDate: string | null; +} + +interface ValidationExceptionReport { + ReportDate: string; + FileName: string; + ScreeningName: string; + CohortName: string; + ExceptionCount: number; +} + +export default async function Page({ + searchParams, +}: { + readonly searchParams?: Promise<{ + readonly nhsNumber?: string; + readonly page?: string; + }>; +}) { + const session = await auth(); + const isCohortManager = await canAccessCohortManager(session); + + if (!isCohortManager) { + return ; + } + + const breadcrumbItems = [ + { label: "Home", url: "/" }, + { label: "Search exceptions", url: "/exceptions/search" }, + ]; + + const resolvedSearchParams = searchParams ? await searchParams : {}; + const nhsNumber = resolvedSearchParams.nhsNumber; + const currentPage = Math.max( + 1, + Number.parseInt(resolvedSearchParams.page || "1", 10) + ); + + if (!nhsNumber) { + return ( + <> + +
    +
    +
    +

    Search exceptions by NHS number

    +

    + Please enter an NHS number in the search box in the header. +

    +
    +
    + + + ); + } + + try { + const response = await fetchExceptionsByNhsNumber({ + nhsNumber, + page: currentPage, + pageSize: 10, + }); + + const exceptionDetails: ExceptionDetails[] = + response.Exceptions.Items.map((exception: ApiException) => { + const ruleMapping = getRuleMapping( + exception.RuleId, + exception.RuleDescription + ); + return { + exceptionId: exception.ExceptionId.toString(), + dateCreated: new Date(exception.DateCreated), + shortDescription: ruleMapping.ruleDescription, + nhsNumber: exception.NhsNumber, + serviceNowId: exception.ServiceNowId ?? "", + serviceNowCreatedDate: exception.ServiceNowCreatedDate + ? new Date(exception.ServiceNowCreatedDate) + : undefined, + }; + }); + + const totalCount = response.Exceptions.TotalCount; + const totalPages = response.Exceptions.TotalPages; + const hasNextPage = response.Exceptions.HasNextPage; + const hasPreviousPage = response.Exceptions.HasPreviousPage; + const reports: ValidationExceptionReport[] = response.Reports; + + return ( + <> + +
    +
    +
    +

    + Search results for {nhsNumber} +

    + + {totalCount === 0 ? ( +
    +

    + No results for {nhsNumber} +

    +
    + ) : ( + <> +

    + Exceptions ({totalCount}) +

    + +
    +
    + +
    +
    + + {totalPages > 1 && ( + + )} + + {reports.length > 0 && ( + <> +

    + Associated reports +

    + +
    +
    + + + + + + + + + + + + {reports.map((report, index) => ( + + + + + + + + ))} + +
    + Report date + + File name + + Screening name + + Cohort name + + Exception count +
    + {new Date( + report.ReportDate + ).toLocaleDateString("en-GB")} + + {report.FileName} + + {report.ScreeningName} + + {report.CohortName} + + {report.ExceptionCount} +
    +
    +
    + + )} + + )} +
    +
    +
    + + ); + } catch (error) { + return ( + <> + +
    +
    +
    +

    Search exceptions by NHS number

    +
    +
    +

    + {error instanceof Error + ? error.message + : "An error occurred while fetching exceptions. Please try again."} +

    +
    +
    +
    +
    +
    + + ); + } +} From 956e5700ac3cb9bf130b6e80d19c9c513983d2f2 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Nov 2025 11:32:05 +0000 Subject: [PATCH 07/41] feat: search moved out of header to own component --- .../CohortManager/src/Web/app/components/header.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/application/CohortManager/src/Web/app/components/header.tsx b/application/CohortManager/src/Web/app/components/header.tsx index 94982eb4db..f7fda06776 100644 --- a/application/CohortManager/src/Web/app/components/header.tsx +++ b/application/CohortManager/src/Web/app/components/header.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { auth, signOut } from "@/app/lib/auth"; +import { SearchNhsNumber } from "./search-nhs-number"; interface HeaderProps { readonly serviceName?: string; @@ -47,14 +48,7 @@ export default async function Header({ >
    • - +
    • Date: Thu, 27 Nov 2025 11:32:24 +0000 Subject: [PATCH 08/41] feat: search function --- .../Web/app/components/search-nhs-number.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 application/CohortManager/src/Web/app/components/search-nhs-number.tsx diff --git a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx new file mode 100644 index 0000000000..2f015ff4b3 --- /dev/null +++ b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, FormEvent } from "react"; + +export function SearchNhsNumber() { + const router = useRouter(); + const [nhsNumber, setNhsNumber] = useState(""); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + + const cleanedNhsNumber = nhsNumber.replaceAll(" ", ""); + + if (cleanedNhsNumber.length === 10 && /^\d+$/.test(cleanedNhsNumber)) { + router.push(`/exceptions/search?nhsNumber=${cleanedNhsNumber}`); + } else { + alert("Please enter a valid 10-digit NHS number"); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(e as unknown as FormEvent); + } + }; + + return ( +
      +
      + ); +} From b58d7e1fda85bf569a35c85476ce0951fe195a47 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Nov 2025 11:32:50 +0000 Subject: [PATCH 09/41] feat: fetch updated with fetchExceptionsByNhsNumber endpoint --- .../src/Web/app/lib/fetchExceptions.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts index 8fda1a352b..0e15f93c55 100644 --- a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts +++ b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts @@ -48,3 +48,50 @@ export async function fetchExceptions(params: FetchExceptionsParams = {}) { headers: response.headers, }; } + +type FetchExceptionsByNhsNumberParams = { + nhsNumber: string; + page?: number; + pageSize?: number; +}; + +export async function fetchExceptionsByNhsNumber( + params: FetchExceptionsByNhsNumberParams +) { + const query = new URLSearchParams(); + + query.append("nhsNumber", params.nhsNumber); + query.append("page", (params.page ?? 1).toString()); + query.append("pageSize", (params.pageSize ?? 10).toString()); + + const apiUrl = `${ + process.env.EXCEPTIONS_API_URL + }/api/GetValidationExceptionsByNhsNumber?${query.toString()}`; + + const response = await fetch(apiUrl); + + // If 404, return empty result structure instead of throwing + if (response.status === 404) { + return { + NhsNumber: params.nhsNumber, + Exceptions: { + Items: [], + TotalCount: 0, + Page: params.page ?? 1, + PageSize: params.pageSize ?? 10, + TotalPages: 0, + HasNextPage: false, + HasPreviousPage: false, + }, + Reports: [], + }; + } + + if (!response.ok) { + throw new Error(`Error fetching data: ${response.statusText}`); + } + + const data = await response.json(); + + return data; +} From 17ff964b14cb59a5c8a09022bd713ab4838e4f35 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Nov 2025 11:33:11 +0000 Subject: [PATCH 10/41] feat: ValidationExceptionsByNhsNumberResponse --- ...ValidationExceptionsByNhsNumberResponse.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs diff --git a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs new file mode 100644 index 0000000000..1af107fe69 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs @@ -0,0 +1,31 @@ +namespace Model.DTO; + +/// +/// Response model for NHS number search containing paginated exceptions and associated reports +/// +public class ValidationExceptionsByNhsNumberResponse +{ + public string NhsNumber { get; set; } = string.Empty; + public PaginatedExceptionsResult Exceptions { get; set; } = new(); + public List Reports { get; set; } = new(); +} + +public class PaginatedExceptionsResult +{ + public List Items { get; set; } = new(); + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalPages { get; set; } + public bool HasNextPage { get; set; } + public bool HasPreviousPage { get; set; } +} + +public class ValidationExceptionReport +{ + public DateTime ReportDate { get; set; } + public string? FileName { get; set; } + public string? ScreeningName { get; set; } + public string? CohortName { get; set; } + public int ExceptionCount { get; set; } +} From c948b989c048731649a021f12b3c17f688feea24 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Nov 2025 11:33:43 +0000 Subject: [PATCH 11/41] feat: GetValidationExceptionsByNhsNumber function added --- .../Data/Database/IValidationExceptionData.cs | 2 + .../Data/Database/ValidationExceptionData.cs | 76 +++++++++++++++++++ .../GetValidationExceptions.cs | 59 ++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs index 927bf01769..d632603b24 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs @@ -2,6 +2,7 @@ namespace Data.Database; using Common; using Model; +using Model.DTO; using Model.Enums; public interface IValidationExceptionData @@ -12,4 +13,5 @@ public interface IValidationExceptionData Task RemoveOldException(string nhsNumber, string screeningName); Task UpdateExceptionServiceNowId(int exceptionId, string serviceNowId); Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); + Task GetExceptionsByNhsNumber(string nhsNumber, int page, int pageSize); } diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 8f6eaacaeb..45ae731c59 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -133,6 +133,82 @@ public async Task UpdateExceptionServiceNowId(int exceptio return results.Where(x => x != null).ToList()!; } + public async Task GetExceptionsByNhsNumber(string nhsNumber, int page, int pageSize) + { + var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); + + if (exceptions == null || !exceptions.Any()) + { + return new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = nhsNumber, + Exceptions = new PaginatedExceptionsResult + { + Items = new List(), + TotalCount = 0, + Page = page, + PageSize = pageSize, + TotalPages = 0, + HasNextPage = false, + HasPreviousPage = false + }, + Reports = new List() + }; + } + + var validationExceptions = exceptions + .Select(GetValidationExceptionWithDetails) + .Where(x => x != null) + .Cast() + .ToList(); + + // Sort by date created descending + var sortedExceptions = validationExceptions.OrderByDescending(x => x.DateCreated ?? DateTime.MinValue).ToList(); + + // Calculate pagination + var totalCount = sortedExceptions.Count; + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var skip = (page - 1) * pageSize; + var paginatedItems = sortedExceptions.Skip(skip).Take(pageSize).ToList(); + + // Generate report data grouped by date and file + var reports = validationExceptions + .Where(x => x.ExceptionDate.HasValue && !string.IsNullOrEmpty(x.FileName)) + .GroupBy(x => new + { + Date = x.ExceptionDate.HasValue ? x.ExceptionDate.Value.Date : DateTime.MinValue, + FileName = x.FileName ?? string.Empty, + ScreeningName = x.ScreeningName ?? string.Empty, + CohortName = x.CohortName ?? string.Empty + }) + .Select(g => new ValidationExceptionReport + { + ReportDate = g.Key.Date, + FileName = g.Key.FileName, + ScreeningName = g.Key.ScreeningName, + CohortName = g.Key.CohortName, + ExceptionCount = g.Count() + }) + .OrderByDescending(r => r.ReportDate) + .ToList(); + + return new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = nhsNumber, + Exceptions = new PaginatedExceptionsResult + { + Items = paginatedItems, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + TotalPages = totalPages, + HasNextPage = page < totalPages, + HasPreviousPage = page > 1 + }, + Reports = reports + }; + } + private ServiceResponseModel CreateSuccessResponse(string message) => CreateResponse(true, HttpStatusCode.OK, message); private ServiceResponseModel CreateErrorResponse(string message, HttpStatusCode statusCode) => CreateResponse(false, statusCode, message); diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index e90f849288..e340baac88 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -129,4 +129,63 @@ public async Task UpdateExceptionServiceNowId([HttpTrigger(Aut return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req); } } + + /// + /// Retrieves validation exceptions and reports for a specific NHS number. + /// + /// The HTTP request data containing query parameters. + /// + /// HTTP response containing exceptions and reports in JSON format. + /// Returns 200 OK with data, 400 Bad Request for validation errors, or 500 Internal Server Error. + /// + [Function(nameof(GetValidationExceptionsByNhsNumber))] + public async Task GetValidationExceptionsByNhsNumber([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) + { + var nhsNumber = req.Query["nhsNumber"]; + var page = _httpParserHelper.GetQueryParameterAsInt(req, "page", 1); + var pageSize = _httpParserHelper.GetQueryParameterAsInt(req, "pageSize", 10); + + // Validate NHS number + if (string.IsNullOrWhiteSpace(nhsNumber)) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "NHS number is required."); + } + + // Remove spaces and validate format + nhsNumber = nhsNumber.Replace(" ", ""); + if (!IsValidNhsNumber(nhsNumber)) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "Invalid NHS number format. Must be 10 digits."); + } + + // Validate pagination parameters + if (page < 1) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "Page must be greater than 0."); + } + + if (pageSize < 1 || pageSize > 100) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "PageSize must be between 1 and 100."); + } + + try + { + var result = await _validationData.GetExceptionsByNhsNumber(nhsNumber, page, pageSize); + + return _createResponse.CreateHttpResponse(HttpStatusCode.OK, req, JsonSerializer.Serialize(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving validation exceptions for NHS number: {NhsNumber}", nhsNumber); + return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req); + } + } + + private static bool IsValidNhsNumber(string nhsNumber) + { + return !string.IsNullOrWhiteSpace(nhsNumber) + && nhsNumber.Length == 10 + && nhsNumber.All(char.IsDigit); + } } From a1868164b6397bd228cbe26d37e73e8035167a1a Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Nov 2025 13:32:56 +0000 Subject: [PATCH 12/41] feat: added showing item numbers --- .../src/Web/app/exceptions/search/page.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/application/CohortManager/src/Web/app/exceptions/search/page.tsx b/application/CohortManager/src/Web/app/exceptions/search/page.tsx index 4442de343d..6f18754563 100644 --- a/application/CohortManager/src/Web/app/exceptions/search/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/search/page.tsx @@ -105,6 +105,12 @@ export default async function Page({ const hasNextPage = response.Exceptions.HasNextPage; const hasPreviousPage = response.Exceptions.HasPreviousPage; const reports: ValidationExceptionReport[] = response.Reports; + const pageSize = 10; + const startItem = totalCount > 0 ? (currentPage - 1) * pageSize + 1 : 0; + const endItem = + totalCount > 0 + ? Math.min(startItem + response.Exceptions.Items.length - 1, totalCount) + : 0; return ( <> @@ -117,16 +123,22 @@ export default async function Page({ {totalCount === 0 ? ( -
      -

      - No results for {nhsNumber} -

      +
      +

      No results for {nhsNumber}

      ) : ( <> -

      - Exceptions ({totalCount}) -

      +
      +

      + Exceptions +

      +

      + Showing {startItem} to {endItem} of {totalCount} results +

      +
      From a97fb3604f7d5e87281932d08914b73089652a80 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Nov 2025 16:02:48 +0000 Subject: [PATCH 13/41] feat: Header tidy and search button added --- .../src/Web/app/components/header.tsx | 139 +++++++++--------- .../Web/app/components/search-nhs-number.tsx | 20 ++- .../CohortManager/src/Web/app/globals.scss | 138 +++++++++++++++++ 3 files changed, 225 insertions(+), 72 deletions(-) diff --git a/application/CohortManager/src/Web/app/components/header.tsx b/application/CohortManager/src/Web/app/components/header.tsx index f7fda06776..16c1f75bda 100644 --- a/application/CohortManager/src/Web/app/components/header.tsx +++ b/application/CohortManager/src/Web/app/components/header.tsx @@ -14,75 +14,80 @@ export default async function Header({ return (
      -
      - -
      + - NHS - - - {serviceName} - -
      + + {serviceName} + +
      + + {session?.user && ( +
      + +
      + )} - {session?.user && ( - - )} + {session?.user && ( + + )} +
      ); diff --git a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx index 2f015ff4b3..844ae4a79d 100644 --- a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx +++ b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx @@ -1,5 +1,4 @@ "use client"; - import { useRouter } from "next/navigation"; import { useState, FormEvent } from "react"; @@ -9,9 +8,7 @@ export function SearchNhsNumber() { const handleSubmit = (e: FormEvent) => { e.preventDefault(); - const cleanedNhsNumber = nhsNumber.replaceAll(" ", ""); - if (cleanedNhsNumber.length === 10 && /^\d+$/.test(cleanedNhsNumber)) { router.push(`/exceptions/search?nhsNumber=${cleanedNhsNumber}`); } else { @@ -27,7 +24,7 @@ export function SearchNhsNumber() { }; return ( -
      + setNhsNumber(e.target.value)} onKeyDown={handleKeyDown} - style={{ height: "8px", padding: "8px" }} /> +
      ); } diff --git a/application/CohortManager/src/Web/app/globals.scss b/application/CohortManager/src/Web/app/globals.scss index 6b9cb0f77c..197c99728e 100644 --- a/application/CohortManager/src/Web/app/globals.scss +++ b/application/CohortManager/src/Web/app/globals.scss @@ -162,3 +162,141 @@ color: #00703c; } +// Header layout +.nhsuk-header__content { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 16px; +} + +.nhsuk-header__service { + flex-shrink: 0; +} + +.nhsuk-header__search { + flex: 1; + max-width: 400px; + margin: 0 32px; + + > div { + display: contents; + } +} + +.nhsuk-header__account { + flex-shrink: 0; +} + +.nhsuk-header__account-list { + display: flex; + align-items: center; + gap: 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.nhsuk-header__account-item { + display: flex; + align-items: center; + gap: 8px; +} + +.nhsuk-icon__user { + width: 24px; + height: 24px; + fill: white; +} + +// Search form styling +.app-search-form { + display: flex; + align-items: center; + margin-bottom: 0; + width: 100%; +} + +.app-search-form .nhsuk-input { + min-width: 250px; + flex: 1; + margin-bottom: 0; + margin-right: 8px; + height: 40px; + padding: 8px 12px; +} + +.app-search-form .nhsuk-header__search-submit { + margin-left: 0; + flex-shrink: 0; + background-color: #005ea5; + border: 2px solid #005ea5; + color: white; + padding: 8px 12px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background-color: #004080; + border-color: #004080; + } + + &:focus { + outline: 3px solid #ffeb3b; + outline-offset: 0; + } +} + +.app-search-form .nhsuk-icon--search { + fill: currentColor; +} + +// Header layout +.nhsuk-header__content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.nhsuk-header__search { + flex: 1; + max-width: 400px; + margin: 0 32px; +} + +// Search form +.app-search-form { + display: flex; + align-items: center; + position: relative; +} + +.app-search-form .nhsuk-input { + flex: 1; + min-width: 250px; + height: 40px; + padding-right: 48px; +} + +.nhsuk-header__search-submit { + background-color: #005ea5; + border: 2px solid #005ea5; + color: white; + padding: 8px 12px; + height: 40px; + display: flex; + align-items: center; + cursor: pointer; + margin-left: -2px; + border-radius: 0 4px 4px 0; + + &:hover { + background-color: #004080; + border-color: #004080; + } +} From 96f2de26d1fad36d52638f6937a17a23c13fa614 Mon Sep 17 00:00:00 2001 From: warren Date: Mon, 1 Dec 2025 20:43:20 +0000 Subject: [PATCH 14/41] feat: pagination fixed, reports added, page count made dynamic, response object refactored --- .../Data/Database/IValidationExceptionData.cs | 4 + .../Data/Database/ValidationExceptionData.cs | 136 +++++++------ ...ValidationExceptionsByNhsNumberResponse.cs | 1 + .../GetValidationExceptions.cs | 51 ++--- .../src/Web/app/exceptions/search/page.tsx | 187 ++++++++---------- .../src/Web/app/lib/fetchExceptions.ts | 31 +-- .../src/Web/app/reports/[date]/page.tsx | 22 ++- 7 files changed, 216 insertions(+), 216 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs index d632603b24..c05b8dd247 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs @@ -1,5 +1,6 @@ namespace Data.Database; +using System.Linq.Expressions; using Common; using Model; using Model.DTO; @@ -13,5 +14,8 @@ public interface IValidationExceptionData Task RemoveOldException(string nhsNumber, string screeningName); Task UpdateExceptionServiceNowId(int exceptionId, string serviceNowId); Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); + Task?> GetByFilter(Expression> filter); + List ProcessExceptions(IEnumerable exceptions); + List GenerateReports(List validationExceptions); Task GetExceptionsByNhsNumber(string nhsNumber, int page, int pageSize); } diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 45ae731c59..083dc444ab 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -2,6 +2,7 @@ namespace Data.Database; using System; using System.Data; +using System.Linq.Expressions; using System.Net; using System.Text.Json; using System.Threading.Tasks; @@ -129,57 +130,30 @@ public async Task UpdateExceptionServiceNowId(int exceptio if (filteredExceptions == null || !filteredExceptions.Any()) return []; - var results = filteredExceptions.Select(GetValidationExceptionWithDetails); - return results.Where(x => x != null).ToList()!; + return ProcessExceptions(filteredExceptions); } - - public async Task GetExceptionsByNhsNumber(string nhsNumber, int page, int pageSize) + public async Task?> GetByFilter(Expression> filter) { - var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); - - if (exceptions == null || !exceptions.Any()) - { - return new ValidationExceptionsByNhsNumberResponse - { - NhsNumber = nhsNumber, - Exceptions = new PaginatedExceptionsResult - { - Items = new List(), - TotalCount = 0, - Page = page, - PageSize = pageSize, - TotalPages = 0, - HasNextPage = false, - HasPreviousPage = false - }, - Reports = new List() - }; - } - - var validationExceptions = exceptions - .Select(GetValidationExceptionWithDetails) - .Where(x => x != null) - .Cast() - .ToList(); - - // Sort by date created descending - var sortedExceptions = validationExceptions.OrderByDescending(x => x.DateCreated ?? DateTime.MinValue).ToList(); + return await _validationExceptionDataServiceClient.GetByFilter(filter); + } - // Calculate pagination - var totalCount = sortedExceptions.Count; - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - var skip = (page - 1) * pageSize; - var paginatedItems = sortedExceptions.Skip(skip).Take(pageSize).ToList(); + public List ProcessExceptions(IEnumerable exceptions) + { + var results = exceptions.Select(GetValidationExceptionWithDetails); + return results.Where(x => x != null).ToList()!; + } - // Generate report data grouped by date and file - var reports = validationExceptions + public List GenerateReports(List validationExceptions) + { + return validationExceptions .Where(x => x.ExceptionDate.HasValue && !string.IsNullOrEmpty(x.FileName)) .GroupBy(x => new { Date = x.ExceptionDate.HasValue ? x.ExceptionDate.Value.Date : DateTime.MinValue, FileName = x.FileName ?? string.Empty, ScreeningName = x.ScreeningName ?? string.Empty, - CohortName = x.CohortName ?? string.Empty + CohortName = x.CohortName ?? string.Empty, + Category = x.Category }) .Select(g => new ValidationExceptionReport { @@ -187,26 +161,11 @@ public async Task GetExceptionsByNhsNum FileName = g.Key.FileName, ScreeningName = g.Key.ScreeningName, CohortName = g.Key.CohortName, + Category = g.Key.Category, ExceptionCount = g.Count() }) .OrderByDescending(r => r.ReportDate) .ToList(); - - return new ValidationExceptionsByNhsNumberResponse - { - NhsNumber = nhsNumber, - Exceptions = new PaginatedExceptionsResult - { - Items = paginatedItems, - TotalCount = totalCount, - Page = page, - PageSize = pageSize, - TotalPages = totalPages, - HasNextPage = page < totalPages, - HasPreviousPage = page > 1 - }, - Reports = reports - }; } private ServiceResponseModel CreateSuccessResponse(string message) => CreateResponse(true, HttpStatusCode.OK, message); @@ -387,4 +346,67 @@ private static List SortExceptions(SortOrder? sortOrder, IE ? [.. filteredList.OrderBy(dateProperty)] : [.. filteredList.OrderByDescending(dateProperty)]; } + + public async Task GetExceptionsByNhsNumber(string nhsNumber, int page, int pageSize) + { + // Validate NHS number + if (string.IsNullOrWhiteSpace(nhsNumber)) + { + throw new ArgumentException("NHS number is required.", nameof(nhsNumber)); + } + + // Remove spaces and validate format + nhsNumber = nhsNumber.Replace(" ", ""); + if (!IsValidNhsNumber(nhsNumber)) + { + throw new ArgumentException("Invalid NHS number format. Must be 10 digits.", nameof(nhsNumber)); + } + + var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); + if (exceptions == null || !exceptions.Any()) + { + return new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = nhsNumber, + Exceptions = new PaginatedExceptionsResult(), + Reports = [] + }; + } + + // Use the extracted common method + var validationExceptions = ProcessExceptions(exceptions); + var sortedExceptions = validationExceptions.OrderByDescending(x => x.DateCreated ?? DateTime.MinValue); + + // Manual pagination since we don't have the pagination service in the data layer + var totalCount = sortedExceptions.Count(); + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var skip = (page - 1) * pageSize; + var paginatedItems = sortedExceptions.Skip(skip).Take(pageSize).ToList(); + + // Generate reports from the full dataset + var reports = GenerateReports(validationExceptions); + + return new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = nhsNumber, + Exceptions = new PaginatedExceptionsResult + { + Items = paginatedItems, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + TotalPages = totalPages, + HasNextPage = page < totalPages, + HasPreviousPage = page > 1 + }, + Reports = reports + }; + } + + private static bool IsValidNhsNumber(string nhsNumber) + { + return !string.IsNullOrWhiteSpace(nhsNumber) + && nhsNumber.Length == 10 + && nhsNumber.All(char.IsDigit); + } } diff --git a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs index 1af107fe69..9b12339a9a 100644 --- a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs @@ -27,5 +27,6 @@ public class ValidationExceptionReport public string? FileName { get; set; } public string? ScreeningName { get; set; } public string? CohortName { get; set; } + public int? Category { get; set; } public int ExceptionCount { get; set; } } diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index e340baac88..a3fd33a126 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -10,6 +10,7 @@ namespace NHS.CohortManager.ScreeningDataServices; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Model; +using Model.DTO; using Model.Enums; /// @@ -145,35 +146,28 @@ public async Task GetValidationExceptionsByNhsNumber([HttpTrig var page = _httpParserHelper.GetQueryParameterAsInt(req, "page", 1); var pageSize = _httpParserHelper.GetQueryParameterAsInt(req, "pageSize", 10); - // Validate NHS number - if (string.IsNullOrWhiteSpace(nhsNumber)) - { - return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "NHS number is required."); - } - - // Remove spaces and validate format - nhsNumber = nhsNumber.Replace(" ", ""); - if (!IsValidNhsNumber(nhsNumber)) - { - return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "Invalid NHS number format. Must be 10 digits."); - } - - // Validate pagination parameters - if (page < 1) - { - return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "Page must be greater than 0."); - } - - if (pageSize < 1 || pageSize > 100) - { - return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "PageSize must be between 1 and 100."); - } - try { var result = await _validationData.GetExceptionsByNhsNumber(nhsNumber, page, pageSize); - return _createResponse.CreateHttpResponse(HttpStatusCode.OK, req, JsonSerializer.Serialize(result)); + // Convert to PaginationResult format for header generation + var paginationResult = new PaginationResult + { + Items = result.Exceptions.Items, + TotalItems = result.Exceptions.TotalCount, + CurrentPage = result.Exceptions.Page, + TotalPages = result.Exceptions.TotalPages, + HasNextPage = result.Exceptions.HasNextPage, + HasPreviousPage = result.Exceptions.HasPreviousPage, + IsFirstPage = result.Exceptions.Page == 1 + }; + + var headers = _paginationService.AddNavigationHeaders(req, paginationResult); + return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(result), headers); + } + catch (ArgumentException ex) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, ex.Message); } catch (Exception ex) { @@ -181,11 +175,4 @@ public async Task GetValidationExceptionsByNhsNumber([HttpTrig return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req); } } - - private static bool IsValidNhsNumber(string nhsNumber) - { - return !string.IsNullOrWhiteSpace(nhsNumber) - && nhsNumber.Length == 10 - && nhsNumber.All(char.IsDigit); - } } diff --git a/application/CohortManager/src/Web/app/exceptions/search/page.tsx b/application/CohortManager/src/Web/app/exceptions/search/page.tsx index 6f18754563..bde5764b38 100644 --- a/application/CohortManager/src/Web/app/exceptions/search/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/search/page.tsx @@ -7,6 +7,7 @@ import ExceptionsTable from "@/app/components/exceptionsTable"; import Breadcrumb from "@/app/components/breadcrumb"; import Unauthorised from "@/app/components/unauthorised"; import { ExceptionDetails } from "@/app/types"; +import Pagination from "@/app/components/pagination"; export const metadata: Metadata = { title: `Search exceptions by NHS number - ${process.env.SERVICE_NAME} - NHS`, @@ -27,6 +28,7 @@ interface ValidationExceptionReport { FileName: string; ScreeningName: string; CohortName: string; + Category: number | null; ExceptionCount: number; } @@ -83,7 +85,7 @@ export default async function Page({ }); const exceptionDetails: ExceptionDetails[] = - response.Exceptions.Items.map((exception: ApiException) => { + response.data.Exceptions.Items.map((exception: ApiException) => { const ruleMapping = getRuleMapping( exception.RuleId, exception.RuleDescription @@ -100,16 +102,15 @@ export default async function Page({ }; }); - const totalCount = response.Exceptions.TotalCount; - const totalPages = response.Exceptions.TotalPages; - const hasNextPage = response.Exceptions.HasNextPage; - const hasPreviousPage = response.Exceptions.HasPreviousPage; - const reports: ValidationExceptionReport[] = response.Reports; + const linkHeader = response.headers?.get("Link") || response.linkHeader; + const totalPages = response.data.Exceptions.TotalPages || 1; const pageSize = 10; + const totalCount = response.data.Exceptions.TotalCount || 0; + const reports: ValidationExceptionReport[] = response.data.Reports; const startItem = totalCount > 0 ? (currentPage - 1) * pageSize + 1 : 0; const endItem = totalCount > 0 - ? Math.min(startItem + response.Exceptions.Items.length - 1, totalCount) + ? Math.min(startItem + response.data.Exceptions.Items.length - 1, totalCount) : 0; return ( @@ -147,113 +148,87 @@ export default async function Page({
      {totalPages > 1 && ( - + `/exceptions/search?nhsNumber=${nhsNumber}&page=${page}`} + /> )} - {reports.length > 0 && ( - <> -

      - Associated reports -

      +

      + Reports +

      -
      -
      +
      +
      + {(() => { + const filteredReports = reports.filter(report => report.Category === 12 || report.Category === 13); + + return filteredReports.length > 0 ? ( - - - - - - - - - - - {reports.map((report, index) => ( - - - - - - + + + + + - ))} - -
      - Report date - - File name - - Screening name - - Cohort name - - Exception count -
      - {new Date( - report.ReportDate - ).toLocaleDateString("en-GB")} - - {report.FileName} - - {report.ScreeningName} - - {report.CohortName} - - {report.ExceptionCount} -
      + Date + + Demographic change + + Action +
      -
      -
      - - )} + + + {filteredReports.map((report, index) => { + const categoryLabel = report.Category === 13 ? "NHS Number Change" : "Possible Confusion"; + const reportDate = new Date(report.ReportDate).toISOString().split('T')[0]; + const reportUrl = `/reports/${reportDate}?category=${report.Category}&nhsNumber=${nhsNumber}`; + + return ( + + + {new Date( + report.ReportDate + ).toLocaleDateString("en-GB")} + + + {categoryLabel} + + + + View report + + + + ); + })} + + + ) : ( +

      + No reports for {nhsNumber}. +

      + ); + })()} +
      +
      )}
      diff --git a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts index 0e15f93c55..f6efbd32e6 100644 --- a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts +++ b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts @@ -73,17 +73,21 @@ export async function fetchExceptionsByNhsNumber( // If 404, return empty result structure instead of throwing if (response.status === 404) { return { - NhsNumber: params.nhsNumber, - Exceptions: { - Items: [], - TotalCount: 0, - Page: params.page ?? 1, - PageSize: params.pageSize ?? 10, - TotalPages: 0, - HasNextPage: false, - HasPreviousPage: false, + data: { + NhsNumber: params.nhsNumber, + Exceptions: { + Items: [], + TotalCount: 0, + Page: params.page ?? 1, + PageSize: params.pageSize ?? 10, + TotalPages: 0, + HasNextPage: false, + HasPreviousPage: false, + }, + Reports: [], }, - Reports: [], + linkHeader: null, + headers: response.headers, }; } @@ -92,6 +96,11 @@ export async function fetchExceptionsByNhsNumber( } const data = await response.json(); + const linkHeader = response.headers.get("Link"); - return data; + return { + data, + linkHeader, + headers: response.headers, + }; } diff --git a/application/CohortManager/src/Web/app/reports/[date]/page.tsx b/application/CohortManager/src/Web/app/reports/[date]/page.tsx index 8a31c11449..f3a911adb3 100644 --- a/application/CohortManager/src/Web/app/reports/[date]/page.tsx +++ b/application/CohortManager/src/Web/app/reports/[date]/page.tsx @@ -21,6 +21,7 @@ export default async function Page(props: { readonly searchParams?: Promise<{ readonly category?: string; readonly page?: string; + readonly nhsNumber?: string; }>; }) { const session = await auth(); @@ -41,6 +42,7 @@ export default async function Page(props: { ? await props.searchParams : {}; const categoryId = Number(resolvedSearchParams.category); + const nhsNumber = resolvedSearchParams.nhsNumber; const currentPage = Math.max( 1, Number.parseInt(resolvedSearchParams.page || "1", 10) @@ -66,13 +68,15 @@ export default async function Page(props: { const reportData = response.data; const linkHeader = response.headers?.get("Link") || response.linkHeader; + // Filter items by NHS number if provided + const filteredItems = nhsNumber + ? reportData.Items.filter((item: ExceptionAPIDetails) => item.NhsNumber === nhsNumber) + : reportData.Items; + const totalPages = reportData.TotalPages || 1; - const totalItems = Number(reportData.TotalItems) || 0; - const startItem = totalItems > 0 ? (currentPage - 1) * pageSize + 1 : 0; - const endItem = - totalItems > 0 - ? Math.min(startItem + reportData.Items.length - 1, totalItems) - : 0; + const totalItems = nhsNumber ? filteredItems.length : Number(reportData.TotalItems) || 0; + const startItem = totalItems > 0 ? (nhsNumber ? 1 : (currentPage - 1) * pageSize + 1) : 0; + const endItem = totalItems > 0 ? totalItems : 0; return ( <> @@ -97,12 +101,10 @@ export default async function Page(props: {
      - {reportData.Items?.length ? ( + {filteredItems?.length ? ( ) : (

      No report available for {formatDate(date)}

      From 4b716e7f6e70f01a36302ecb33d6bfa96bd2587e Mon Sep 17 00:00:00 2001 From: warren Date: Mon, 1 Dec 2025 20:47:48 +0000 Subject: [PATCH 15/41] feat: added missing feedback component from report details page --- application/CohortManager/src/Web/app/reports/[date]/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/CohortManager/src/Web/app/reports/[date]/page.tsx b/application/CohortManager/src/Web/app/reports/[date]/page.tsx index f3a911adb3..714975ab73 100644 --- a/application/CohortManager/src/Web/app/reports/[date]/page.tsx +++ b/application/CohortManager/src/Web/app/reports/[date]/page.tsx @@ -8,6 +8,7 @@ import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; import ReportsInformationTable from "@/app/components/reportsInformationTable"; import Pagination from "@/app/components/pagination"; +import UserFeedback from "@/app/components/userFeedback"; import { type ExceptionAPIDetails } from "@/app/types/exceptionsApi"; export const metadata: Metadata = { @@ -126,6 +127,7 @@ export default async function Page(props: { )}
      + ); From bdd12edf8732d47c212d590e4b0c5df11e398210 Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 2 Dec 2025 10:41:14 +0000 Subject: [PATCH 16/41] feat: generate report logic --- .../Shared/Data/Database/ValidationExceptionData.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 083dc444ab..9d022e2eed 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -145,11 +145,11 @@ public List ProcessExceptions(IEnumerable GenerateReports(List validationExceptions) { - return validationExceptions - .Where(x => x.ExceptionDate.HasValue && !string.IsNullOrEmpty(x.FileName)) + return [.. validationExceptions + .Where(x => x.Category.HasValue && (x.Category.Value == 12 || x.Category.Value == 13)) .GroupBy(x => new { - Date = x.ExceptionDate.HasValue ? x.ExceptionDate.Value.Date : DateTime.MinValue, + Date = x.DateCreated.HasValue ? x.DateCreated.Value.Date : DateTime.Now.Date, FileName = x.FileName ?? string.Empty, ScreeningName = x.ScreeningName ?? string.Empty, CohortName = x.CohortName ?? string.Empty, @@ -164,8 +164,7 @@ public List GenerateReports(List Category = g.Key.Category, ExceptionCount = g.Count() }) - .OrderByDescending(r => r.ReportDate) - .ToList(); + .OrderByDescending(r => r.ReportDate)]; } private ServiceResponseModel CreateSuccessResponse(string message) => CreateResponse(true, HttpStatusCode.OK, message); From 3cb9521ff894e9c8288258071c6001d8c61516ef Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 2 Dec 2025 11:31:48 +0000 Subject: [PATCH 17/41] feat: refactor service methods, and calling function, move paginationResult DTO, front end structure change --- .../Common/Pagination/IPaginationService.cs | 1 + .../Common/Pagination/PaginationService.cs | 1 + .../Data/Database/IValidationExceptionData.cs | 5 +- .../Data/Database/ValidationExceptionData.cs | 106 ++++++++---------- .../DTO}/PaginationResult.cs | 2 +- ...ValidationExceptionsByNhsNumberResponse.cs | 15 +-- .../GetValidationExceptions.cs | 27 ++--- .../src/Web/app/exceptions/search/page.tsx | 2 +- .../src/Web/app/lib/fetchExceptions.ts | 6 +- 9 files changed, 68 insertions(+), 97 deletions(-) rename application/CohortManager/src/Functions/Shared/{Common/Pagination => Model/DTO}/PaginationResult.cs (92%) diff --git a/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs b/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs index 6e698143b3..e3d9293b94 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs @@ -1,6 +1,7 @@ namespace Common; using Microsoft.Azure.Functions.Worker.Http; +using Model.Pagination; public interface IPaginationService { diff --git a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs b/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs index b72f3331b2..fca5804a0b 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs @@ -1,6 +1,7 @@ namespace Common; using Microsoft.Azure.Functions.Worker.Http; +using Model.Pagination; public class PaginationService : IPaginationService { diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs index c05b8dd247..7e6e32ee8a 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs @@ -1,6 +1,8 @@ namespace Data.Database; +using System; using System.Linq.Expressions; +using System.Threading.Tasks; using Common; using Model; using Model.DTO; @@ -15,7 +17,8 @@ public interface IValidationExceptionData Task UpdateExceptionServiceNowId(int exceptionId, string serviceNowId); Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); Task?> GetByFilter(Expression> filter); + Task> GetExceptionsByNhsNumber(string nhsNumber); + Task> GetReportsByNhsNumber(string nhsNumber); List ProcessExceptions(IEnumerable exceptions); List GenerateReports(List validationExceptions); - Task GetExceptionsByNhsNumber(string nhsNumber, int page, int pageSize); } diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 9d022e2eed..b8822cc201 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -18,10 +18,10 @@ public class ValidationExceptionData : IValidationExceptionData { private readonly ILogger _logger; private readonly IDataServiceClient _validationExceptionDataServiceClient; + public ValidationExceptionData( ILogger logger, - IDataServiceClient validationExceptionDataServiceClient, - IDataServiceClient demographicDataServiceClient + IDataServiceClient validationExceptionDataServiceClient ) { _logger = logger; @@ -50,12 +50,12 @@ IDataServiceClient demographicDataServiceClient return GetValidationExceptionWithDetails(exception); } - public async Task Create(ValidationException exception) { var exceptionToUpdate = new ExceptionManagement().FromValidationException(exception); return await _validationExceptionDataServiceClient.Add(exceptionToUpdate); } + public async Task RemoveOldException(string nhsNumber, string screeningName) { var exceptions = await GetExceptionRecords(nhsNumber, screeningName); @@ -64,7 +64,6 @@ public async Task RemoveOldException(string nhsNumber, string screeningNam return false; } - // we only need to get the last unresolved exception for the nhs number and screening service var validationExceptionToUpdate = exceptions.Where(x => DateToString(x.DateResolved) == "9999-12-31") .OrderByDescending(x => x.DateCreated).FirstOrDefault(); @@ -132,11 +131,52 @@ public async Task UpdateExceptionServiceNowId(int exceptio return ProcessExceptions(filteredExceptions); } + public async Task?> GetByFilter(Expression> filter) { return await _validationExceptionDataServiceClient.GetByFilter(filter); } + public async Task> GetExceptionsByNhsNumber(string nhsNumber) + { + if (string.IsNullOrWhiteSpace(nhsNumber)) + { + throw new ArgumentException("NHS number is required.", nameof(nhsNumber)); + } + + nhsNumber = nhsNumber.Replace(" ", ""); + if (!IsValidNhsNumber(nhsNumber)) + { + throw new ArgumentException("Invalid NHS number format. Must be 10 digits.", nameof(nhsNumber)); + } + + var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); + if (exceptions == null || !exceptions.Any()) + { + return new List().AsQueryable(); + } + + var validationExceptions = ProcessExceptions(exceptions); + return validationExceptions.OrderByDescending(x => x.DateCreated).AsQueryable(); + } + + public async Task> GetReportsByNhsNumber(string nhsNumber) + { + if (string.IsNullOrWhiteSpace(nhsNumber) || !IsValidNhsNumber(nhsNumber.Replace(" ", ""))) + { + return []; + } + + var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); + if (exceptions == null || !exceptions.Any()) + { + return []; + } + + var validationExceptions = ProcessExceptions(exceptions); + return GenerateReports(validationExceptions); + } + public List ProcessExceptions(IEnumerable exceptions) { var results = exceptions.Select(GetValidationExceptionWithDetails); @@ -311,10 +351,8 @@ private ServiceResponseModel CreateResponse(bool success, HttpStatusCode statusC private async Task?> GetExceptionRecords(string nhsNumber, string screeningName) { - var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber && x.ScreeningName == screeningName); return exceptions?.ToList(); - } private static string DateToString(DateTime? datetime) @@ -346,62 +384,6 @@ private static List SortExceptions(SortOrder? sortOrder, IE : [.. filteredList.OrderByDescending(dateProperty)]; } - public async Task GetExceptionsByNhsNumber(string nhsNumber, int page, int pageSize) - { - // Validate NHS number - if (string.IsNullOrWhiteSpace(nhsNumber)) - { - throw new ArgumentException("NHS number is required.", nameof(nhsNumber)); - } - - // Remove spaces and validate format - nhsNumber = nhsNumber.Replace(" ", ""); - if (!IsValidNhsNumber(nhsNumber)) - { - throw new ArgumentException("Invalid NHS number format. Must be 10 digits.", nameof(nhsNumber)); - } - - var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); - if (exceptions == null || !exceptions.Any()) - { - return new ValidationExceptionsByNhsNumberResponse - { - NhsNumber = nhsNumber, - Exceptions = new PaginatedExceptionsResult(), - Reports = [] - }; - } - - // Use the extracted common method - var validationExceptions = ProcessExceptions(exceptions); - var sortedExceptions = validationExceptions.OrderByDescending(x => x.DateCreated ?? DateTime.MinValue); - - // Manual pagination since we don't have the pagination service in the data layer - var totalCount = sortedExceptions.Count(); - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - var skip = (page - 1) * pageSize; - var paginatedItems = sortedExceptions.Skip(skip).Take(pageSize).ToList(); - - // Generate reports from the full dataset - var reports = GenerateReports(validationExceptions); - - return new ValidationExceptionsByNhsNumberResponse - { - NhsNumber = nhsNumber, - Exceptions = new PaginatedExceptionsResult - { - Items = paginatedItems, - TotalCount = totalCount, - Page = page, - PageSize = pageSize, - TotalPages = totalPages, - HasNextPage = page < totalPages, - HasPreviousPage = page > 1 - }, - Reports = reports - }; - } - private static bool IsValidNhsNumber(string nhsNumber) { return !string.IsNullOrWhiteSpace(nhsNumber) diff --git a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationResult.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/PaginationResult.cs similarity index 92% rename from application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationResult.cs rename to application/CohortManager/src/Functions/Shared/Model/DTO/PaginationResult.cs index 86e3f04c89..749633d01e 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationResult.cs +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/PaginationResult.cs @@ -1,4 +1,4 @@ -namespace Common; +namespace Model.Pagination; public class PaginationResult { diff --git a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs index 9b12339a9a..55a6584b0e 100644 --- a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs @@ -1,26 +1,17 @@ namespace Model.DTO; +using Model.Pagination; + /// /// Response model for NHS number search containing paginated exceptions and associated reports /// public class ValidationExceptionsByNhsNumberResponse { public string NhsNumber { get; set; } = string.Empty; - public PaginatedExceptionsResult Exceptions { get; set; } = new(); + public PaginationResult Exceptions { get; set; } = new(); public List Reports { get; set; } = new(); } -public class PaginatedExceptionsResult -{ - public List Items { get; set; } = new(); - public int TotalCount { get; set; } - public int Page { get; set; } - public int PageSize { get; set; } - public int TotalPages { get; set; } - public bool HasNextPage { get; set; } - public bool HasPreviousPage { get; set; } -} - public class ValidationExceptionReport { public DateTime ReportDate { get; set; } diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index a3fd33a126..ba60fa4852 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -12,6 +12,7 @@ namespace NHS.CohortManager.ScreeningDataServices; using Model; using Model.DTO; using Model.Enums; +using Model.Pagination; /// /// Azure Function for retrieving and managing validation exceptions. @@ -134,11 +135,6 @@ public async Task UpdateExceptionServiceNowId([HttpTrigger(Aut /// /// Retrieves validation exceptions and reports for a specific NHS number. /// - /// The HTTP request data containing query parameters. - /// - /// HTTP response containing exceptions and reports in JSON format. - /// Returns 200 OK with data, 400 Bad Request for validation errors, or 500 Internal Server Error. - /// [Function(nameof(GetValidationExceptionsByNhsNumber))] public async Task GetValidationExceptionsByNhsNumber([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) { @@ -148,21 +144,18 @@ public async Task GetValidationExceptionsByNhsNumber([HttpTrig try { - var result = await _validationData.GetExceptionsByNhsNumber(nhsNumber, page, pageSize); - - // Convert to PaginationResult format for header generation - var paginationResult = new PaginationResult + var exceptionsQueryable = await _validationData.GetExceptionsByNhsNumber(nhsNumber); + var paginatedExceptions = _paginationService.GetPaginatedResult(exceptionsQueryable, page, pageSize); + var reports = await _validationData.GetReportsByNhsNumber(nhsNumber); + var result = new ValidationExceptionsByNhsNumberResponse { - Items = result.Exceptions.Items, - TotalItems = result.Exceptions.TotalCount, - CurrentPage = result.Exceptions.Page, - TotalPages = result.Exceptions.TotalPages, - HasNextPage = result.Exceptions.HasNextPage, - HasPreviousPage = result.Exceptions.HasPreviousPage, - IsFirstPage = result.Exceptions.Page == 1 + NhsNumber = nhsNumber, + Exceptions = paginatedExceptions, + Reports = reports }; - var headers = _paginationService.AddNavigationHeaders(req, paginationResult); + var headers = _paginationService.AddNavigationHeaders(req, paginatedExceptions); + return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(result), headers); } catch (ArgumentException ex) diff --git a/application/CohortManager/src/Web/app/exceptions/search/page.tsx b/application/CohortManager/src/Web/app/exceptions/search/page.tsx index bde5764b38..00ee890556 100644 --- a/application/CohortManager/src/Web/app/exceptions/search/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/search/page.tsx @@ -105,7 +105,7 @@ export default async function Page({ const linkHeader = response.headers?.get("Link") || response.linkHeader; const totalPages = response.data.Exceptions.TotalPages || 1; const pageSize = 10; - const totalCount = response.data.Exceptions.TotalCount || 0; + const totalCount = response.data.Exceptions.TotalItems || 0; const reports: ValidationExceptionReport[] = response.data.Reports; const startItem = totalCount > 0 ? (currentPage - 1) * pageSize + 1 : 0; const endItem = diff --git a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts index f6efbd32e6..caeac05c6a 100644 --- a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts +++ b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts @@ -77,12 +77,12 @@ export async function fetchExceptionsByNhsNumber( NhsNumber: params.nhsNumber, Exceptions: { Items: [], - TotalCount: 0, - Page: params.page ?? 1, - PageSize: params.pageSize ?? 10, + TotalItems: 0, + CurrentPage: params.page ?? 1, TotalPages: 0, HasNextPage: false, HasPreviousPage: false, + IsFirstPage: true, }, Reports: [], }, From 0157b9dd3432acc0c9ac97040ca4df6b20055f6e Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 2 Dec 2025 14:53:37 +0000 Subject: [PATCH 18/41] feat: Error handling --- .../Web/app/components/search-nhs-number.tsx | 81 +++++++++++++++---- .../CohortManager/src/Web/app/globals.scss | 14 ++++ 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx index 844ae4a79d..b75de98b3d 100644 --- a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx +++ b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx @@ -1,40 +1,89 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState, FormEvent } from "react"; +import { useState, FormEvent, useRef } from "react"; export function SearchNhsNumber() { const router = useRouter(); const [nhsNumber, setNhsNumber] = useState(""); + const errorSpanRef = useRef(null); + + const isValidNhsNumber = (value: string): boolean => { + const cleaned = value.replaceAll(" ", ""); + return cleaned.length === 10 && /^\d+$/.test(cleaned); + }; + + const showError = (message: string) => { + if (errorSpanRef.current) { + errorSpanRef.current.textContent = message; + errorSpanRef.current.classList.add("visible"); + } + }; + + const hideError = () => { + if (errorSpanRef.current) { + errorSpanRef.current.classList.remove("visible"); + } + }; + + const hasError = (): boolean => { + return errorSpanRef.current?.classList.contains("visible") || false; + }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); + const cleanedNhsNumber = nhsNumber.replaceAll(" ", ""); - if (cleanedNhsNumber.length === 10 && /^\d+$/.test(cleanedNhsNumber)) { - router.push(`/exceptions/search?nhsNumber=${cleanedNhsNumber}`); - } else { - alert("Please enter a valid 10-digit NHS number"); + + if (!isValidNhsNumber(nhsNumber)) { + showError("Please enter a valid 10-digit NHS number"); + return; + } + + hideError(); + router.push(`/exceptions/search?nhsNumber=${cleanedNhsNumber}`); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setNhsNumber(value); + + if (hasError() && value.trim()) { + hideError(); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - handleSubmit(e as unknown as FormEvent); + const form = e.currentTarget.form; + if (form) { + form.requestSubmit(); + } } }; return (
      - setNhsNumber(e.target.value)} - onKeyDown={handleKeyDown} - /> +
      + + + Error: + +
      -
      + + + ); } diff --git a/application/CohortManager/src/Web/app/globals.scss b/application/CohortManager/src/Web/app/globals.scss index d8646beeb3..f511bfa84c 100644 --- a/application/CohortManager/src/Web/app/globals.scss +++ b/application/CohortManager/src/Web/app/globals.scss @@ -162,127 +162,29 @@ color: #00703c; } -// Header layout -.nhsuk-header__service { - flex-shrink: 0; -} - -.nhsuk-header__search { - flex: 1; - max-width: 400px; - margin: 0 32px; - - > div { - display: contents; - } -} - -.nhsuk-header__account { - flex-shrink: 0; -} - -.nhsuk-header__account-list { - display: flex; - align-items: center; - gap: 16px; - margin: 0; - padding: 0; - list-style: none; -} - -.nhsuk-header__account-item { +.nhsuk-header__content { display: flex; align-items: center; - gap: 8px; + justify-content: space-between; + flex-wrap: nowrap; } -.nhsuk-icon__user { - width: 24px; - height: 24px; - fill: white; +.nhsuk-header__service { + white-space: nowrap; + flex-shrink: 0; } -// Search form styling -.app-search-form .nhsuk-input { - min-width: 250px; +.nhsuk-header__search { flex: 1; - margin-bottom: 0; - margin-right: 8px; - height: 40px; - padding: 8px 12px; + margin: 0 100px; + min-width: 250px; } -.app-search-form .nhsuk-header__search-submit { - margin-left: 0; - flex-shrink: 0; - background-color: #005ea5; - border: 2px solid #005ea5; - color: white; - padding: 8px 12px; - height: 40px; +.nhsuk-header__search-form { display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - - &:hover { - background-color: #004080; - border-color: #004080; - } - - &:focus { - outline: 3px solid #ffeb3b; - outline-offset: 0; - } -} - -.app-search-form .nhsuk-icon--search { fill: currentColor; } -// Header layout -.nhsuk-header__content { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -// Search form -.app-search-form { - display: flex; - align-items: center; - position: relative; -} - -.nhsuk-header__search-submit { - background-color: #005ea5; - border: 2px solid #005ea5; - color: white; - padding: 8px 12px; - height: 40px; - display: flex; - align-items: center; - cursor: pointer; - margin-left: -2px; - border-radius: 0 4px 4px 0; - - &:hover { - background-color: #004080; - border-color: #004080; - } -} - -.search-input-container { - position: relative; - flex: 1; -} - -.search-error-message { - display: none; - margin-top: 4px; - - &.visible { - display: block; - } +.nhsuk-header__account { + flex-shrink: 0; } From daf3fdacd7c38f7740d77e94e9a83ab8f463f2b7 Mon Sep 17 00:00:00 2001 From: warren Date: Fri, 5 Dec 2025 13:18:18 +0000 Subject: [PATCH 28/41] feat: server side rendering on conditionalHeaderSearch Component --- .../src/Web/app/components/conditionalHeaderSearch.tsx | 8 +++----- .../CohortManager/src/Web/app/components/header.tsx | 5 ++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/application/CohortManager/src/Web/app/components/conditionalHeaderSearch.tsx b/application/CohortManager/src/Web/app/components/conditionalHeaderSearch.tsx index 220408c0a4..33e8dfde70 100644 --- a/application/CohortManager/src/Web/app/components/conditionalHeaderSearch.tsx +++ b/application/CohortManager/src/Web/app/components/conditionalHeaderSearch.tsx @@ -1,17 +1,15 @@ -"use client"; -import { usePathname } from "next/navigation"; - interface ConditionalHeaderSearchProps { readonly children: React.ReactNode; + readonly pathname: string; } export function ConditionalHeaderSearch({ children, + pathname, }: ConditionalHeaderSearchProps) { - const pathname = usePathname(); const isNoResultsPage = pathname === "/exceptions/noResults"; - // Don't render the header search if we're on the error page + // Don't render the header search if we're on the no results page if (isNoResultsPage) { return null; } diff --git a/application/CohortManager/src/Web/app/components/header.tsx b/application/CohortManager/src/Web/app/components/header.tsx index a88b3d4603..74cb9ef819 100644 --- a/application/CohortManager/src/Web/app/components/header.tsx +++ b/application/CohortManager/src/Web/app/components/header.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { headers } from "next/headers"; import { auth, signOut } from "@/app/lib/auth"; import { SearchNhsNumber } from "./search-nhs-number"; import { ConditionalHeaderSearch } from "./conditionalHeaderSearch"; @@ -11,6 +12,8 @@ export default async function Header({ serviceName = process.env.SERVICE_NAME, }: Readonly) { const session = await auth(); + const headersList = await headers(); + const pathname = headersList.get("x-invoke-path") || headersList.get("x-url") || ""; return (
      @@ -44,7 +47,7 @@ export default async function Header({ {session?.user && (
      - +
      From 910cf9eed90e980b1c89d5c5222f7ad6719ed508 Mon Sep 17 00:00:00 2001 From: warren Date: Fri, 5 Dec 2025 13:47:20 +0000 Subject: [PATCH 29/41] chore: Sonaqube img fix --- application/CohortManager/src/Web/app/components/header.tsx | 1 - .../CohortManager/src/Web/app/components/search-nhs-number.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/application/CohortManager/src/Web/app/components/header.tsx b/application/CohortManager/src/Web/app/components/header.tsx index 74cb9ef819..f7ed2a5a50 100644 --- a/application/CohortManager/src/Web/app/components/header.tsx +++ b/application/CohortManager/src/Web/app/components/header.tsx @@ -32,7 +32,6 @@ export default async function Header({ height="40" width="100" focusable="false" - role="img" aria-hidden="true" > NHS diff --git a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx index be2247325a..1c60849e50 100644 --- a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx +++ b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx @@ -67,7 +67,6 @@ export function SearchNhsNumber() { width="16" height="16" focusable="false" - role="img" aria-label="Search" > Search From 2396f507fed41f61fb487370856b3f1cfc4b249d Mon Sep 17 00:00:00 2001 From: warren Date: Fri, 5 Dec 2025 15:06:27 +0000 Subject: [PATCH 30/41] chore: styling --- application/CohortManager/src/Web/app/globals.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/application/CohortManager/src/Web/app/globals.scss b/application/CohortManager/src/Web/app/globals.scss index f511bfa84c..6100477dab 100644 --- a/application/CohortManager/src/Web/app/globals.scss +++ b/application/CohortManager/src/Web/app/globals.scss @@ -176,10 +176,13 @@ .nhsuk-header__search { flex: 1; - margin: 0 100px; min-width: 250px; } +.nhsuk-header .nhsuk-header__search { + margin: 0 100px; +} + .nhsuk-header__search-form { display: flex; fill: currentColor; From e40f57e193185592b0adbe32f9eca64f40fd468b Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 9 Dec 2025 11:30:07 +0000 Subject: [PATCH 31/41] feat: code tidy and refactor, --- .../Data/Database/IValidationExceptionData.cs | 5 +- .../Data/Database/ValidationExceptionData.cs | 84 ++++++++++--------- ...ValidationExceptionsByNhsNumberResponse.cs | 5 +- .../GetValidationExceptions.cs | 23 +---- .../src/Web/app/lib/fetchExceptions.ts | 3 +- 5 files changed, 51 insertions(+), 69 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs index 7e6e32ee8a..8c9a0363ff 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs @@ -17,8 +17,5 @@ public interface IValidationExceptionData Task UpdateExceptionServiceNowId(int exceptionId, string serviceNowId); Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); Task?> GetByFilter(Expression> filter); - Task> GetExceptionsByNhsNumber(string nhsNumber); - Task> GetReportsByNhsNumber(string nhsNumber); - List ProcessExceptions(IEnumerable exceptions); - List GenerateReports(List validationExceptions); + Task GetExceptionsByNhsNumber(string nhsNumber); } diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index b8822cc201..e9de365077 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -12,6 +12,7 @@ namespace Data.Database; using Model; using Model.DTO; using Model.Enums; +using Model.Pagination; using NHS.CohortManager.Shared.Utilities; public class ValidationExceptionData : IValidationExceptionData @@ -129,7 +130,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio if (filteredExceptions == null || !filteredExceptions.Any()) return []; - return ProcessExceptions(filteredExceptions); + return MapToValidationExceptions(filteredExceptions); } public async Task?> GetByFilter(Expression> filter) @@ -137,7 +138,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio return await _validationExceptionDataServiceClient.GetByFilter(filter); } - public async Task> GetExceptionsByNhsNumber(string nhsNumber) + public async Task GetExceptionsByNhsNumber(string nhsNumber) { if (string.IsNullOrWhiteSpace(nhsNumber)) { @@ -153,58 +154,61 @@ public async Task> GetExceptionsByNhsNumber(stri var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); if (exceptions == null || !exceptions.Any()) { - return new List().AsQueryable(); - } - - var validationExceptions = ProcessExceptions(exceptions); - return validationExceptions.OrderByDescending(x => x.DateCreated).AsQueryable(); - } - - public async Task> GetReportsByNhsNumber(string nhsNumber) - { - if (string.IsNullOrWhiteSpace(nhsNumber) || !IsValidNhsNumber(nhsNumber.Replace(" ", ""))) - { - return []; - } - - var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); - if (exceptions == null || !exceptions.Any()) - { - return []; + return new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = nhsNumber, + Exceptions = new PaginationResult { Items = [] }, + Reports = [] + }; } - var validationExceptions = ProcessExceptions(exceptions); - return GenerateReports(validationExceptions); - } + // Map exceptions to validation exceptions with details + var validationExceptions = exceptions + .Select(GetValidationExceptionWithDetails) + .Where(x => x != null) + .Cast() + .OrderByDescending(x => x.DateCreated) + .ToList(); - public List ProcessExceptions(IEnumerable exceptions) - { - var results = exceptions.Select(GetValidationExceptionWithDetails); - return results.Where(x => x != null).ToList()!; - } - - public List GenerateReports(List validationExceptions) - { - return [.. validationExceptions + // Generate reports from category 12 and 13 exceptions + var reports = validationExceptions .Where(x => x.Category.HasValue && (x.Category.Value == 12 || x.Category.Value == 13)) .GroupBy(x => new { - Date = x.DateCreated.HasValue ? x.DateCreated.Value.Date : DateTime.Now.Date, - FileName = x.FileName ?? string.Empty, - ScreeningName = x.ScreeningName ?? string.Empty, - CohortName = x.CohortName ?? string.Empty, + Date = x.DateCreated?.Date ?? DateTime.Now.Date, Category = x.Category }) .Select(g => new ValidationExceptionReport { ReportDate = g.Key.Date, - FileName = g.Key.FileName, - ScreeningName = g.Key.ScreeningName, - CohortName = g.Key.CohortName, Category = g.Key.Category, ExceptionCount = g.Count() }) - .OrderByDescending(r => r.ReportDate)]; + .OrderByDescending(r => r.ReportDate) + .ToList(); + + var totalItems = validationExceptions.Count; + + return new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = nhsNumber, + Exceptions = new PaginationResult + { + Items = validationExceptions, + TotalItems = totalItems, + TotalPages = 1, + CurrentPage = 1, + IsFirstPage = true, + HasNextPage = false, + HasPreviousPage = false + }, + Reports = reports + }; + } + + private List MapToValidationExceptions(IEnumerable exceptions) + { + return exceptions.Select(GetValidationExceptionWithDetails).Where(x => x != null).ToList()!; } private ServiceResponseModel CreateSuccessResponse(string message) => CreateResponse(true, HttpStatusCode.OK, message); diff --git a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs index bcc2b88d01..d57dae7cd5 100644 --- a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs @@ -9,15 +9,12 @@ public class ValidationExceptionsByNhsNumberResponse { public string NhsNumber { get; set; } = string.Empty; public PaginationResult Exceptions { get; set; } = new() { Items = [] }; - public List Reports { get; set; } = new(); + public List Reports { get; set; } = []; } public class ValidationExceptionReport { public DateTime ReportDate { get; set; } - public string? FileName { get; set; } - public string? ScreeningName { get; set; } - public string? CohortName { get; set; } public int? Category { get; set; } public int ExceptionCount { get; set; } } diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index f316795237..94fdbc1dcc 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -139,29 +139,14 @@ public async Task UpdateExceptionServiceNowId([HttpTrigger(Aut public async Task GetValidationExceptionsByNhsNumber([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) { var nhsNumber = req.Query["nhsNumber"]; - var page = _httpParserHelper.GetQueryParameterAsInt(req, "page", 1); - var pageSize = _httpParserHelper.GetQueryParameterAsInt(req, "pageSize", 10); try { - if (string.IsNullOrWhiteSpace(nhsNumber)) - { - return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "NHS number is required."); - } - - var exceptionsQueryable = await _validationData.GetExceptionsByNhsNumber(nhsNumber); - var paginatedExceptions = _paginationService.GetPaginatedResult(exceptionsQueryable, page, pageSize); - var reports = await _validationData.GetReportsByNhsNumber(nhsNumber); - var result = new ValidationExceptionsByNhsNumberResponse - { - NhsNumber = nhsNumber, - Exceptions = paginatedExceptions, - Reports = reports - }; - - var headers = _paginationService.AddNavigationHeaders(req, paginatedExceptions); + var result = await _validationData.GetExceptionsByNhsNumber(nhsNumber!); - return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(result), headers); + return result.Exceptions.Items.Any() + ? _createResponse.CreateHttpResponse(HttpStatusCode.OK, req, JsonSerializer.Serialize(result)) + : _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req); } catch (ArgumentException ex) { diff --git a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts index caeac05c6a..8fc36e1cd1 100644 --- a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts +++ b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts @@ -70,8 +70,7 @@ export async function fetchExceptionsByNhsNumber( const response = await fetch(apiUrl); - // If 404, return empty result structure instead of throwing - if (response.status === 404) { + if (response.status === 204 || response.status === 404) { return { data: { NhsNumber: params.nhsNumber, From 1a18c6454edf2eda54b6bb49a736b216c5c9e62e Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 9 Dec 2025 12:25:45 +0000 Subject: [PATCH 32/41] refactor: GetExceptionsWithReportsByNhsNumber refactor, controller reworked --- .../Data/Database/IValidationExceptionData.cs | 2 +- .../Data/Database/ValidationExceptionData.cs | 28 ++----------------- .../GetValidationExceptions.cs | 23 ++++++++++++--- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs index 8c9a0363ff..e861809f45 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs @@ -17,5 +17,5 @@ public interface IValidationExceptionData Task UpdateExceptionServiceNowId(int exceptionId, string serviceNowId); Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); Task?> GetByFilter(Expression> filter); - Task GetExceptionsByNhsNumber(string nhsNumber); + Task<(IQueryable Exceptions, List Reports, string NhsNumber)> GetExceptionsWithReportsByNhsNumber(string nhsNumber); } diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index e9de365077..ebb7396cb8 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -12,7 +12,6 @@ namespace Data.Database; using Model; using Model.DTO; using Model.Enums; -using Model.Pagination; using NHS.CohortManager.Shared.Utilities; public class ValidationExceptionData : IValidationExceptionData @@ -138,7 +137,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio return await _validationExceptionDataServiceClient.GetByFilter(filter); } - public async Task GetExceptionsByNhsNumber(string nhsNumber) + public async Task<(IQueryable Exceptions, List Reports, string NhsNumber)> GetExceptionsWithReportsByNhsNumber(string nhsNumber) { if (string.IsNullOrWhiteSpace(nhsNumber)) { @@ -154,12 +153,7 @@ public async Task GetExceptionsByNhsNum var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); if (exceptions == null || !exceptions.Any()) { - return new ValidationExceptionsByNhsNumberResponse - { - NhsNumber = nhsNumber, - Exceptions = new PaginationResult { Items = [] }, - Reports = [] - }; + return (Enumerable.Empty().AsQueryable(), [], nhsNumber); } // Map exceptions to validation exceptions with details @@ -187,23 +181,7 @@ public async Task GetExceptionsByNhsNum .OrderByDescending(r => r.ReportDate) .ToList(); - var totalItems = validationExceptions.Count; - - return new ValidationExceptionsByNhsNumberResponse - { - NhsNumber = nhsNumber, - Exceptions = new PaginationResult - { - Items = validationExceptions, - TotalItems = totalItems, - TotalPages = 1, - CurrentPage = 1, - IsFirstPage = true, - HasNextPage = false, - HasPreviousPage = false - }, - Reports = reports - }; + return (validationExceptions.AsQueryable(), reports, nhsNumber); } private List MapToValidationExceptions(IEnumerable exceptions) diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index 94fdbc1dcc..0b5e0521ca 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -139,14 +139,29 @@ public async Task UpdateExceptionServiceNowId([HttpTrigger(Aut public async Task GetValidationExceptionsByNhsNumber([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) { var nhsNumber = req.Query["nhsNumber"]; + var page = _httpParserHelper.GetQueryParameterAsInt(req, "page", 1); + var pageSize = _httpParserHelper.GetQueryParameterAsInt(req, "pageSize", 10); try { - var result = await _validationData.GetExceptionsByNhsNumber(nhsNumber!); + var (exceptions, reports, nhsNumberResult) = await _validationData.GetExceptionsWithReportsByNhsNumber(nhsNumber!); - return result.Exceptions.Items.Any() - ? _createResponse.CreateHttpResponse(HttpStatusCode.OK, req, JsonSerializer.Serialize(result)) - : _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req); + if (!exceptions.Any()) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req); + } + + var paginatedExceptions = _paginationService.GetPaginatedResult(exceptions, page, pageSize); + var headers = _paginationService.AddNavigationHeaders(req, paginatedExceptions); + + var result = new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = nhsNumberResult, + Exceptions = paginatedExceptions, + Reports = reports + }; + + return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(result), headers); } catch (ArgumentException ex) { From 2fd02634bfe0579d63b72912575a7b4e9810daec Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 9 Dec 2025 12:30:36 +0000 Subject: [PATCH 33/41] refactor: abstract methods --- .../Data/Database/ValidationExceptionData.cs | 94 +++++++++++-------- 1 file changed, 55 insertions(+), 39 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index ebb7396cb8..7e78c59a53 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -139,49 +139,16 @@ public async Task UpdateExceptionServiceNowId(int exceptio public async Task<(IQueryable Exceptions, List Reports, string NhsNumber)> GetExceptionsWithReportsByNhsNumber(string nhsNumber) { - if (string.IsNullOrWhiteSpace(nhsNumber)) - { - throw new ArgumentException("NHS number is required.", nameof(nhsNumber)); - } + var validatedNhsNumber = ValidateNhsNumber(nhsNumber); + var validationExceptions = await GetValidationExceptionsByNhsNumber(validatedNhsNumber); - nhsNumber = nhsNumber.Replace(" ", ""); - if (!IsValidNhsNumber(nhsNumber)) + if (validationExceptions.Count == 0) { - throw new ArgumentException("Invalid NHS number format. Must be 10 digits.", nameof(nhsNumber)); + return (Enumerable.Empty().AsQueryable(), [], validatedNhsNumber); } - var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); - if (exceptions == null || !exceptions.Any()) - { - return (Enumerable.Empty().AsQueryable(), [], nhsNumber); - } - - // Map exceptions to validation exceptions with details - var validationExceptions = exceptions - .Select(GetValidationExceptionWithDetails) - .Where(x => x != null) - .Cast() - .OrderByDescending(x => x.DateCreated) - .ToList(); - - // Generate reports from category 12 and 13 exceptions - var reports = validationExceptions - .Where(x => x.Category.HasValue && (x.Category.Value == 12 || x.Category.Value == 13)) - .GroupBy(x => new - { - Date = x.DateCreated?.Date ?? DateTime.Now.Date, - Category = x.Category - }) - .Select(g => new ValidationExceptionReport - { - ReportDate = g.Key.Date, - Category = g.Key.Category, - ExceptionCount = g.Count() - }) - .OrderByDescending(r => r.ReportDate) - .ToList(); - - return (validationExceptions.AsQueryable(), reports, nhsNumber); + var reports = GenerateExceptionReports(validationExceptions); + return (validationExceptions.AsQueryable(), reports, validatedNhsNumber); } private List MapToValidationExceptions(IEnumerable exceptions) @@ -366,10 +333,59 @@ private static List SortExceptions(SortOrder? sortOrder, IE : [.. filteredList.OrderByDescending(dateProperty)]; } + private static string ValidateNhsNumber(string nhsNumber) + { + if (string.IsNullOrWhiteSpace(nhsNumber)) + { + throw new ArgumentException("NHS number is required.", nameof(nhsNumber)); + } + + var validNhsNumber = nhsNumber.Replace(" ", ""); + if (!IsValidNhsNumber(validNhsNumber)) + { + throw new ArgumentException("Invalid NHS number format. Must be 10 digits.", nameof(nhsNumber)); + } + + return validNhsNumber; + } + private static bool IsValidNhsNumber(string nhsNumber) { return !string.IsNullOrWhiteSpace(nhsNumber) && nhsNumber.Length == 10 && nhsNumber.All(char.IsDigit); } + + private async Task> GetValidationExceptionsByNhsNumber(string nhsNumber) + { + var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); + if (exceptions == null || !exceptions.Any()) + { + return []; + } + + return [.. exceptions + .Select(GetValidationExceptionWithDetails) + .Where(x => x != null) + .Cast() + .OrderByDescending(x => x.DateCreated)]; + } + + private static List GenerateExceptionReports(List validationExceptions) + { + return [.. validationExceptions + .Where(x => x.Category.HasValue && (x.Category.Value == 12 || x.Category.Value == 13)) + .GroupBy(x => new + { + Date = x.DateCreated?.Date ?? DateTime.Now.Date, + Category = x.Category + }) + .Select(g => new ValidationExceptionReport + { + ReportDate = g.Key.Date, + Category = g.Key.Category, + ExceptionCount = g.Count() + }) + .OrderByDescending(r => r.ReportDate)]; + } } From 799764bf4e6f699d64b7d972b29d0fc07a6d2ddc Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 9 Dec 2025 12:48:32 +0000 Subject: [PATCH 34/41] test: unit tests added --- .../ValidationExceptionDataTests.cs | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs index 5d99a41986..98d425d2a8 100644 --- a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs +++ b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs @@ -31,8 +31,9 @@ public ValidationExceptionDataTests() new() { ExceptionId = 2, CohortName = "Cohort2", DateCreated = DateTime.UtcNow.Date.AddDays(-1), NhsNumber = "2222222222", RuleDescription = "RuleB", Category = 3, ServiceNowId = "ServiceNow2", ServiceNowCreatedDate = DateTime.UtcNow.Date.AddDays(-1) }, new() { ExceptionId = 3, CohortName = "Cohort3", DateCreated = DateTime.UtcNow.Date.AddDays(-2), NhsNumber = "3333333333", RuleDescription = "RuleC", Category = 3, ServiceNowId = null }, new() { ExceptionId = 4, CohortName = "Cohort4", DateCreated = DateTime.Today.AddDays(-3), NhsNumber = "4444444444", RuleDescription = "RuleD", Category = 3, ServiceNowId = null }, - new() { ExceptionId = 5, CohortName = "Cohort5", DateCreated = DateTime.UtcNow.Date, NhsNumber = "5555555555", RuleDescription = "Confusion Rule", Category = 12, ServiceNowId = null }, - new() { ExceptionId = 6, CohortName = "Cohort6", DateCreated = DateTime.UtcNow.Date.AddDays(-1), NhsNumber = "6666666666", RuleDescription = "Superseded Rule", Category = 13, ServiceNowId = null } + new() { ExceptionId = 5, CohortName = "Cohort5", DateCreated = DateTime.UtcNow.Date, NhsNumber = "9998136431", RuleDescription = "Confusion Rule", Category = 12, ServiceNowId = null, ErrorRecord = "{\"NhsNumber\":\"9998136431\",\"FirstName\":\"John\",\"FamilyName\":\"Doe\"}" }, + new() { ExceptionId = 6, CohortName = "Cohort6", DateCreated = DateTime.UtcNow.Date.AddDays(-1), NhsNumber = "9998136431", RuleDescription = "Superseded Rule", Category = 13, ServiceNowId = null, ErrorRecord = "{\"NhsNumber\":\"9998136431\",\"FirstName\":\"Jane\",\"FamilyName\":\"Smith\"}" }, + new() { ExceptionId = 7, CohortName = "Cohort7", DateCreated = DateTime.UtcNow.Date.AddDays(-2), NhsNumber = "9998136431", RuleDescription = "Other Rule", Category = 5, ServiceNowId = null, ErrorRecord = "{\"NhsNumber\":\"9998136431\",\"FirstName\":\"Bob\",\"FamilyName\":\"Johnson\"}" } }; _exceptionCategory = ExceptionCategory.NBO; } @@ -614,4 +615,85 @@ public async Task UpdateExceptionServiceNowId_NullServiceNowId_ServiceNowIdAndSe _exceptionList[0].ServiceNowId.Should().BeNull(); _exceptionList[0].ServiceNowCreatedDate.Should().BeNull(); } + + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_ValidNhsNumber_ReturnsExceptionsAndReports() + { + var nhsNumber = "9998136431"; + var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); + + var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + exceptions.Should().NotBeNull(); + exceptions.Should().HaveCount(3); + exceptions.Should().BeInDescendingOrder(exceptions => exceptions.DateCreated); + reports.Should().NotBeNull(); + reports.Should().HaveCount(2); + reports.Should().Contain(report => report.Category == 12 && report.ExceptionCount == 1); + reports.Should().Contain(report => report.Category == 13 && report.ExceptionCount == 1); + reports.Should().BeInDescendingOrder(report => report.ReportDate); + resultNhsNumber.Should().Be(nhsNumber); + } + + [DataRow("999 813 6431 ", "9998136431")] + [DataRow("99 98 13 64 31 ", "9998136431")] + [DataRow("999 813 6431", "9998136431")] + [DataRow("9998 13 6431", "9998136431")] + [DataRow(" 9998136431 ", "9998136431")] + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_NhsNumberWithSpaces_RemovesSpacesAndReturnsData(string inputNhsNumber, string expectedNhsNumber) + { + var testExceptions = _exceptionList.Where(e => e.NhsNumber == expectedNhsNumber).ToList(); + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); + + var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(inputNhsNumber); + + exceptions.Should().HaveCount(3); + resultNhsNumber.Should().Be(expectedNhsNumber); + reports.Should().HaveCount(2); + } + + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_NoExceptionsFound_ReturnsEmptyResults() + { + var nhsNumber = "1234567890"; + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(new List()); + + var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + exceptions.Should().NotBeNull(); + exceptions.Should().BeEmpty(); + reports.Should().NotBeNull(); + reports.Should().BeEmpty(); + resultNhsNumber.Should().Be(nhsNumber); + } + + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_OnlyNonReportCategories_ReturnsEmptyReports() + { + var nhsNumber = "3333333333"; + var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); + + var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + exceptions.Should().HaveCount(1); + reports.Should().BeEmpty(); + resultNhsNumber.Should().Be(nhsNumber); + } + + [DataRow(null, typeof(ArgumentException))] + [DataRow("", typeof(ArgumentException))] + [DataRow(" ", typeof(ArgumentException))] + [DataRow("123456789", typeof(ArgumentException))] + [DataRow("12345678901", typeof(ArgumentException))] + [DataRow("123456789A", typeof(ArgumentException))] + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_InvalidNhsNumber_ThrowsArgumentException(string invalidNhsNumber, Type expectedException) + { + var act = async () => await validationExceptionData.GetExceptionsWithReportsByNhsNumber(invalidNhsNumber); + + await act.Should().ThrowAsync(); + } } From a1bbbac276b7e296078923fdd23ebb626f0b90af Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 9 Dec 2025 15:58:39 +0000 Subject: [PATCH 35/41] chore: encodeURIComponent --- .../CohortManager/src/Web/app/components/search-nhs-number.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx index 1c60849e50..05f4a0e88b 100644 --- a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx +++ b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx @@ -20,7 +20,7 @@ export function SearchNhsNumber() { return; } - router.push(`/exceptions/search?nhsNumber=${cleanedNhsNumber}`); + router.push(`/exceptions/search?nhsNumber=${encodeURIComponent(cleanedNhsNumber)}`); }; const handleInputChange = (e: React.ChangeEvent) => { From e173121d8e0793706fcce8b65f5dee8ebe704496 Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 9 Dec 2025 18:37:09 +0000 Subject: [PATCH 36/41] fix: removed wrong ExceptionCategory --- .../Functions/Shared/Data/Database/ValidationExceptionData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 7e78c59a53..814aa491ac 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -119,7 +119,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio public async Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory) { - if (exceptionCategory is not (ExceptionCategory.Confusion or ExceptionCategory.Superseded or ExceptionCategory.NBO)) + if (exceptionCategory is not (ExceptionCategory.Confusion or ExceptionCategory.Superseded)) { return []; } From 497e01774db44aa51c5324af3f85fa50c62d6ac6 Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 9 Dec 2025 18:51:10 +0000 Subject: [PATCH 37/41] fix: type needed for all --- .../Functions/Shared/Data/Database/ValidationExceptionData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 814aa491ac..7e78c59a53 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -119,7 +119,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio public async Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory) { - if (exceptionCategory is not (ExceptionCategory.Confusion or ExceptionCategory.Superseded)) + if (exceptionCategory is not (ExceptionCategory.Confusion or ExceptionCategory.Superseded or ExceptionCategory.NBO)) { return []; } From 9dcac24355824cc3e9f154ed150dbbe80cfe24ea Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 11 Dec 2025 15:27:10 +0000 Subject: [PATCH 38/41] fix: NhsNumber logging removed --- .../GetValidationExceptions/GetValidationExceptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index 0b5e0521ca..cc5475225a 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -169,7 +169,7 @@ public async Task GetValidationExceptionsByNhsNumber([HttpTrig } catch (Exception ex) { - _logger.LogError(ex, "Error retrieving validation exceptions for NHS number: {NhsNumber}", nhsNumber); + _logger.LogError(ex, "Error retrieving validation exceptions"); return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req); } } From 4f7afd51af789a731050e22a9e9875ab5e289dce Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 11 Dec 2025 16:09:59 +0000 Subject: [PATCH 39/41] refactor: addressing PR comments, removed logging, changed return type, refactor response object --- .../Data/Database/IValidationExceptionData.cs | 8 +- .../Data/Database/ValidationExceptionData.cs | 51 +++++-------- ...ValidationExceptionsByNhsNumberResponse.cs | 3 +- .../GetValidationExceptions.cs | 33 +++++---- .../ValidationExceptionDataTests.cs | 74 +++++++------------ 5 files changed, 74 insertions(+), 95 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs index e861809f45..5ae6f96f47 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs @@ -11,11 +11,11 @@ namespace Data.Database; public interface IValidationExceptionData { Task Create(ValidationException exception); - Task?> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory); + Task> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory); Task GetExceptionById(int exceptionId); Task RemoveOldException(string nhsNumber, string screeningName); Task UpdateExceptionServiceNowId(int exceptionId, string serviceNowId); - Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); - Task?> GetByFilter(Expression> filter); - Task<(IQueryable Exceptions, List Reports, string NhsNumber)> GetExceptionsWithReportsByNhsNumber(string nhsNumber); + Task> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); + Task> GetByFilter(Expression> filter); + Task GetExceptionsWithReportsByNhsNumber(string nhsNumber); } diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 7e78c59a53..eb655bb23f 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -28,7 +28,7 @@ IDataServiceClient validationExceptionDataServiceClient _validationExceptionDataServiceClient = validationExceptionDataServiceClient; } - public async Task?> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory) + public async Task> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory) { var category = (int)exceptionCategory; var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.Category != null && x.Category.Value == category); @@ -117,7 +117,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio } } - public async Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory) + public async Task> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory) { if (exceptionCategory is not (ExceptionCategory.Confusion or ExceptionCategory.Superseded or ExceptionCategory.NBO)) { @@ -132,23 +132,33 @@ public async Task UpdateExceptionServiceNowId(int exceptio return MapToValidationExceptions(filteredExceptions); } - public async Task?> GetByFilter(Expression> filter) + public async Task> GetByFilter(Expression> filter) { - return await _validationExceptionDataServiceClient.GetByFilter(filter); + var result = await _validationExceptionDataServiceClient.GetByFilter(filter) ?? Enumerable.Empty(); + return result.ToList(); } - public async Task<(IQueryable Exceptions, List Reports, string NhsNumber)> GetExceptionsWithReportsByNhsNumber(string nhsNumber) + public async Task GetExceptionsWithReportsByNhsNumber(string nhsNumber) { - var validatedNhsNumber = ValidateNhsNumber(nhsNumber); - var validationExceptions = await GetValidationExceptionsByNhsNumber(validatedNhsNumber); + var validationExceptions = await GetValidationExceptionsByNhsNumber(nhsNumber); if (validationExceptions.Count == 0) { - return (Enumerable.Empty().AsQueryable(), [], validatedNhsNumber); + return new ValidationExceptionsByNhsNumberResponse + { + Exceptions = [], + Reports = [], + NhsNumber = nhsNumber + }; } var reports = GenerateExceptionReports(validationExceptions); - return (validationExceptions.AsQueryable(), reports, validatedNhsNumber); + return new ValidationExceptionsByNhsNumberResponse + { + Exceptions = validationExceptions, + Reports = reports, + NhsNumber = nhsNumber + }; } private List MapToValidationExceptions(IEnumerable exceptions) @@ -206,7 +216,7 @@ private List MapToValidationExceptions(IEnumerable SortExceptions(SortOrder? sortOrder, IE : [.. filteredList.OrderByDescending(dateProperty)]; } - private static string ValidateNhsNumber(string nhsNumber) - { - if (string.IsNullOrWhiteSpace(nhsNumber)) - { - throw new ArgumentException("NHS number is required.", nameof(nhsNumber)); - } - - var validNhsNumber = nhsNumber.Replace(" ", ""); - if (!IsValidNhsNumber(validNhsNumber)) - { - throw new ArgumentException("Invalid NHS number format. Must be 10 digits.", nameof(nhsNumber)); - } - return validNhsNumber; - } - - private static bool IsValidNhsNumber(string nhsNumber) - { - return !string.IsNullOrWhiteSpace(nhsNumber) - && nhsNumber.Length == 10 - && nhsNumber.All(char.IsDigit); - } private async Task> GetValidationExceptionsByNhsNumber(string nhsNumber) { diff --git a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs index d57dae7cd5..d995e81b40 100644 --- a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs @@ -8,8 +8,9 @@ namespace Model.DTO; public class ValidationExceptionsByNhsNumberResponse { public string NhsNumber { get; set; } = string.Empty; - public PaginationResult Exceptions { get; set; } = new() { Items = [] }; + public List Exceptions { get; set; } = []; public List Reports { get; set; } = []; + public PaginationResult PaginatedExceptions { get; set; } = new() { Items = [] }; } public class ValidationExceptionReport diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index cc5475225a..56d5a7d96f 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -142,34 +142,41 @@ public async Task GetValidationExceptionsByNhsNumber([HttpTrig var page = _httpParserHelper.GetQueryParameterAsInt(req, "page", 1); var pageSize = _httpParserHelper.GetQueryParameterAsInt(req, "pageSize", 10); + if (string.IsNullOrWhiteSpace(nhsNumber)) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "NHS number is required."); + } + + var cleanedNhsNumber = nhsNumber.Replace(" ", ""); + if (!ValidationHelper.ValidateNHSNumber(cleanedNhsNumber)) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "Invalid NHS number format."); + } + try { - var (exceptions, reports, nhsNumberResult) = await _validationData.GetExceptionsWithReportsByNhsNumber(nhsNumber!); + var result = await _validationData.GetExceptionsWithReportsByNhsNumber(cleanedNhsNumber); - if (!exceptions.Any()) + if (result.Exceptions.Count == 0) { return _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req); } - var paginatedExceptions = _paginationService.GetPaginatedResult(exceptions, page, pageSize); + var paginatedExceptions = _paginationService.GetPaginatedResult(result.Exceptions.AsQueryable(), page, pageSize); var headers = _paginationService.AddNavigationHeaders(req, paginatedExceptions); - var result = new ValidationExceptionsByNhsNumberResponse + var response = new ValidationExceptionsByNhsNumberResponse { - NhsNumber = nhsNumberResult, - Exceptions = paginatedExceptions, - Reports = reports + NhsNumber = result.NhsNumber, + PaginatedExceptions = paginatedExceptions, + Reports = result.Reports }; - return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(result), headers); - } - catch (ArgumentException ex) - { - return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, ex.Message); + return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(response), headers); } catch (Exception ex) { - _logger.LogError(ex, "Error retrieving validation exceptions"); + _logger.LogError(ex, "Error retrieving validation exceptions for provided NHS number"); return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req); } } diff --git a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs index 98d425d2a8..2868019378 100644 --- a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs +++ b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs @@ -623,35 +623,31 @@ public async Task GetExceptionsWithReportsByNhsNumber_ValidNhsNumber_ReturnsExce var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); - var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); - - exceptions.Should().NotBeNull(); - exceptions.Should().HaveCount(3); - exceptions.Should().BeInDescendingOrder(exceptions => exceptions.DateCreated); - reports.Should().NotBeNull(); - reports.Should().HaveCount(2); - reports.Should().Contain(report => report.Category == 12 && report.ExceptionCount == 1); - reports.Should().Contain(report => report.Category == 13 && report.ExceptionCount == 1); - reports.Should().BeInDescendingOrder(report => report.ReportDate); - resultNhsNumber.Should().Be(nhsNumber); + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + result.Exceptions.Should().NotBeNull(); + result.Exceptions.Should().HaveCount(3); + result.Exceptions.Should().BeInDescendingOrder(exceptions => exceptions.DateCreated); + result.Reports.Should().NotBeNull(); + result.Reports.Should().HaveCount(2); + result.Reports.Should().Contain(report => report.Category == 12 && report.ExceptionCount == 1); + result.Reports.Should().Contain(report => report.Category == 13 && report.ExceptionCount == 1); + result.Reports.Should().BeInDescendingOrder(report => report.ReportDate); + result.NhsNumber.Should().Be(nhsNumber); } - [DataRow("999 813 6431 ", "9998136431")] - [DataRow("99 98 13 64 31 ", "9998136431")] - [DataRow("999 813 6431", "9998136431")] - [DataRow("9998 13 6431", "9998136431")] - [DataRow(" 9998136431 ", "9998136431")] [TestMethod] - public async Task GetExceptionsWithReportsByNhsNumber_NhsNumberWithSpaces_RemovesSpacesAndReturnsData(string inputNhsNumber, string expectedNhsNumber) + public async Task GetExceptionsWithReportsByNhsNumber_ValidNhsNumber_ReturnsCorrectData() { - var testExceptions = _exceptionList.Where(e => e.NhsNumber == expectedNhsNumber).ToList(); + var nhsNumber = "9998136431"; + var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); - var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(inputNhsNumber); + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); - exceptions.Should().HaveCount(3); - resultNhsNumber.Should().Be(expectedNhsNumber); - reports.Should().HaveCount(2); + result.Exceptions.Should().HaveCount(3); + result.NhsNumber.Should().Be(nhsNumber); + result.Reports.Should().HaveCount(2); } [TestMethod] @@ -660,13 +656,13 @@ public async Task GetExceptionsWithReportsByNhsNumber_NoExceptionsFound_ReturnsE var nhsNumber = "1234567890"; _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(new List()); - var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); - exceptions.Should().NotBeNull(); - exceptions.Should().BeEmpty(); - reports.Should().NotBeNull(); - reports.Should().BeEmpty(); - resultNhsNumber.Should().Be(nhsNumber); + result.Exceptions.Should().NotBeNull(); + result.Exceptions.Should().BeEmpty(); + result.Reports.Should().NotBeNull(); + result.Reports.Should().BeEmpty(); + result.NhsNumber.Should().Be(nhsNumber); } [TestMethod] @@ -676,24 +672,10 @@ public async Task GetExceptionsWithReportsByNhsNumber_OnlyNonReportCategories_Re var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); - var (exceptions, reports, resultNhsNumber) = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); - - exceptions.Should().HaveCount(1); - reports.Should().BeEmpty(); - resultNhsNumber.Should().Be(nhsNumber); - } - - [DataRow(null, typeof(ArgumentException))] - [DataRow("", typeof(ArgumentException))] - [DataRow(" ", typeof(ArgumentException))] - [DataRow("123456789", typeof(ArgumentException))] - [DataRow("12345678901", typeof(ArgumentException))] - [DataRow("123456789A", typeof(ArgumentException))] - [TestMethod] - public async Task GetExceptionsWithReportsByNhsNumber_InvalidNhsNumber_ThrowsArgumentException(string invalidNhsNumber, Type expectedException) - { - var act = async () => await validationExceptionData.GetExceptionsWithReportsByNhsNumber(invalidNhsNumber); + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); - await act.Should().ThrowAsync(); + result.Exceptions.Should().HaveCount(1); + result.Reports.Should().BeEmpty(); + result.NhsNumber.Should().Be(nhsNumber); } } From b2d1f603120ef70469ddc3a871585ddf19e8b5c6 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 11 Dec 2025 18:37:08 +0000 Subject: [PATCH 40/41] fix: mapping --- .../CohortManager/src/Web/app/exceptions/search/page.tsx | 8 ++++---- .../CohortManager/src/Web/app/lib/fetchExceptions.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/application/CohortManager/src/Web/app/exceptions/search/page.tsx b/application/CohortManager/src/Web/app/exceptions/search/page.tsx index ca89ee2de8..63f1a6e967 100644 --- a/application/CohortManager/src/Web/app/exceptions/search/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/search/page.tsx @@ -233,7 +233,7 @@ export default async function Page({ pageSize: PAGE_SIZE, }); - const totalCount = response.data.Exceptions.TotalItems || 0; + const totalCount = response.data.PaginatedExceptions.TotalItems || 0; // Redirect to No Results page if no results found if (totalCount === 0) { @@ -241,15 +241,15 @@ export default async function Page({ } try { - const exceptionDetails = response.data.Exceptions.Items.map(transformApiException); + const exceptionDetails = response.data.PaginatedExceptions.Items.map(transformApiException); const { startItem, endItem } = calculatePaginationInfo( currentPage, - response.data.Exceptions.Items.length, + response.data.PaginatedExceptions.Items.length, totalCount ); const linkHeader = response.headers?.get("Link") || response.linkHeader; - const totalPages = response.data.Exceptions.TotalPages || 1; + const totalPages = response.data.PaginatedExceptions.TotalPages || 1; const reports = response.data.Reports; return ( diff --git a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts index 8fc36e1cd1..2bea9faf4e 100644 --- a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts +++ b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts @@ -74,7 +74,7 @@ export async function fetchExceptionsByNhsNumber( return { data: { NhsNumber: params.nhsNumber, - Exceptions: { + PaginatedExceptions: { Items: [], TotalItems: 0, CurrentPage: params.page ?? 1, From 912b4dac8098b9daed7aa754acac15717720ba17 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 11 Dec 2025 18:45:05 +0000 Subject: [PATCH 41/41] fix: ISO string causing incorrect date to be parsed for some records --- .../src/Web/app/exceptions/search/page.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/application/CohortManager/src/Web/app/exceptions/search/page.tsx b/application/CohortManager/src/Web/app/exceptions/search/page.tsx index 63f1a6e967..c68a96c7ff 100644 --- a/application/CohortManager/src/Web/app/exceptions/search/page.tsx +++ b/application/CohortManager/src/Web/app/exceptions/search/page.tsx @@ -76,7 +76,16 @@ const getCategoryLabel = (category: number): string => { }; const formatReportDate = (dateString: string): string => { - return new Date(dateString).toISOString().split('T')[0]; + if (new RegExp(/^\d{4}-\d{2}-\d{2}/).exec(dateString)) { + return dateString.split('T')[0]; + } + + const date = new Date(dateString); + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); }; const buildReportUrl = (reportDate: string, category: number, nhsNumber: string): string => {