Skip to content

Commit e7d719f

Browse files
feat: DTOSS 12465 & DTOSS-12527 Remove Dummy GP API validation and PDS lookup (#1872)
* DTOSS 12465 & DTOSS-12527 Remove Dummy GP API validation and PDS ookup * revert changes to visual studio version * revert unnesessory changes * remove response messages * fix unit tests * SonarCube issue fix * core review fix * added docker file, updated compose.core and development.tfvars
1 parent dad06df commit e7d719f

13 files changed

Lines changed: 841 additions & 0 deletions

File tree

application/CohortManager/compose.core.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,20 @@ services:
183183
- CohortDistributionDataServiceURL=http://cohort-distribution-data-service:7992/api/CohortDistributionDataService/
184184
- AcceptableLatencyThresholdMs=500
185185

186+
remove-dummy-gp-code:
187+
container_name: remove-dummy-gp-code
188+
image: cohort-manager-remove-dummy-gp-code
189+
networks: [cohman-network]
190+
build:
191+
context: ./src/Functions/
192+
dockerfile: ParticipantManagementServices/RemoveDummyGPCode/Dockerfile
193+
args:
194+
BASE_IMAGE: ${FUNCTION_BASE_IMAGE}
195+
profiles: [ui]
196+
environment:
197+
- ServiceBusConnectionString_client_internal=Endpoint=sb://service-bus;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;
198+
- ServiceNowParticipantManagementTopic=servicenow-participant-management-topic
199+
- RetrievePdsDemographicURL=http://retrieve-pds-demographic:8082/api/RetrievePDSDemographic
186200

187201
# Screening Data Service
188202
create-exception:

application/CohortManager/src/Functions/Functions.sln

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TransformDataServiceTests",
259259
EndProject
260260
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration-tests", "integration-tests", "{A1000001-0001-0001-0001-00000000000A}"
261261
EndProject
262+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoveDummyGPCode", "ParticipantManagementServices\RemoveDummyGPCode\RemoveDummyGPCode.csproj", "{A48E0AAF-053F-47C2-9862-B748F1423AF2}"
263+
EndProject
264+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoveDummyGPCodeTests", "..\..\..\..\tests\UnitTests\ParticipantManagementServicesTests\RemoveDummyGPCodeTests\RemoveDummyGPCodeTests.csproj", "{80857F35-B5A1-4846-A283-F15B75CBC32A}"
265+
EndProject
262266
Global
263267
GlobalSection(SolutionConfigurationPlatforms) = preSolution
264268
Debug|Any CPU = Debug|Any CPU
@@ -1457,6 +1461,30 @@ Global
14571461
{2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x64.Build.0 = Release|Any CPU
14581462
{2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x86.ActiveCfg = Release|Any CPU
14591463
{2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x86.Build.0 = Release|Any CPU
1464+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1465+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Debug|Any CPU.Build.0 = Debug|Any CPU
1466+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Debug|x64.ActiveCfg = Debug|Any CPU
1467+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Debug|x64.Build.0 = Debug|Any CPU
1468+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Debug|x86.ActiveCfg = Debug|Any CPU
1469+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Debug|x86.Build.0 = Debug|Any CPU
1470+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Release|Any CPU.ActiveCfg = Release|Any CPU
1471+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Release|Any CPU.Build.0 = Release|Any CPU
1472+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Release|x64.ActiveCfg = Release|Any CPU
1473+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Release|x64.Build.0 = Release|Any CPU
1474+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Release|x86.ActiveCfg = Release|Any CPU
1475+
{A48E0AAF-053F-47C2-9862-B748F1423AF2}.Release|x86.Build.0 = Release|Any CPU
1476+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1477+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Debug|Any CPU.Build.0 = Debug|Any CPU
1478+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Debug|x64.ActiveCfg = Debug|Any CPU
1479+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Debug|x64.Build.0 = Debug|Any CPU
1480+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Debug|x86.ActiveCfg = Debug|Any CPU
1481+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Debug|x86.Build.0 = Debug|Any CPU
1482+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Release|Any CPU.ActiveCfg = Release|Any CPU
1483+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Release|Any CPU.Build.0 = Release|Any CPU
1484+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Release|x64.ActiveCfg = Release|Any CPU
1485+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Release|x64.Build.0 = Release|Any CPU
1486+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Release|x86.ActiveCfg = Release|Any CPU
1487+
{80857F35-B5A1-4846-A283-F15B75CBC32A}.Release|x86.Build.0 = Release|Any CPU
14601488
EndGlobalSection
14611489
GlobalSection(SolutionProperties) = preSolution
14621490
HideSolutionNode = FALSE
@@ -1579,5 +1607,7 @@ Global
15791607
{83DD16A7-B7DF-47A4-9DC7-472D9F09EE35} = {2F646680-91A4-F107-74F4-9BF3126A0F16}
15801608
{A1000001-0001-0001-0001-00000000000A} = {2F646680-91A4-F107-74F4-9BF3126A0F16}
15811609
{2ACD4ADF-2769-4D68-8DB4-5094F384C4BC} = {A1000001-0001-0001-0001-00000000000A}
1610+
{A48E0AAF-053F-47C2-9862-B748F1423AF2} = {19500E0D-AAAB-6F02-E24F-82619ACA2290}
1611+
{80857F35-B5A1-4846-A283-F15B75CBC32A} = {AF3A5F34-77F2-7915-5806-A9586C50EB46}
15821612
EndGlobalSection
15831613
EndGlobal
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
ARG BASE_IMAGE
2+
FROM ${BASE_IMAGE} AS function
3+
4+
COPY ./ParticipantManagementServices/RemoveDummyGPCode /app/src/dotnet-function-app
5+
WORKDIR /app/src/dotnet-function-app
6+
7+
RUN --mount=type=cache,target=/root/.nuget/packages \
8+
dotnet publish *.csproj --output /home/site/wwwroot
9+
10+
# To enable ssh & remote debugging on app service change the base image to the one below
11+
# FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0-appservice
12+
FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0
13+
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
14+
AzureFunctionsJobHost__Logging__Console__IsEnabled=true
15+
16+
COPY --from=function ["/home/site/wwwroot", "/home/site/wwwroot"]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace NHS.CohortManager.ParticipantManagementServices;
2+
3+
using HealthChecks.Extensions;
4+
using Microsoft.Azure.Functions.Worker;
5+
using Microsoft.Azure.Functions.Worker.Http;
6+
using Microsoft.Extensions.Diagnostics.HealthChecks;
7+
8+
public class HealthCheckFunction
9+
{
10+
private readonly HealthCheckService _healthCheckService;
11+
12+
public HealthCheckFunction(HealthCheckService healthCheckService)
13+
{
14+
_healthCheckService = healthCheckService;
15+
}
16+
17+
[Function("health")]
18+
public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req)
19+
{
20+
return await HealthCheckServiceExtensions.CreateHealthCheckResponseAsync(req, _healthCheckService);
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace NHS.CohortManager.ParticipantManagementServices.Models;
2+
3+
using System.ComponentModel.DataAnnotations;
4+
using System.Text.Json.Serialization;
5+
6+
public class RemoveDummyGPCodeRequestBody
7+
{
8+
[Required]
9+
[JsonPropertyName("nhs_number")]
10+
public required string NhsNumber { get; set; }
11+
12+
[Required]
13+
[JsonPropertyName("forename")]
14+
public required string Forename { get; set; }
15+
16+
[Required]
17+
[JsonPropertyName("surname")]
18+
public required string Surname { get; set; }
19+
20+
[Required]
21+
[JsonPropertyName("date_of_birth")]
22+
public required DateOnly DateOfBirth { get; set; }
23+
24+
[Required]
25+
[JsonPropertyName("request_id")]
26+
public required string RequestId { get; set; }
27+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Common;
2+
using HealthChecks.Extensions;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Hosting;
5+
using NHS.CohortManager.ParticipantManagementServices;
6+
7+
var host = new HostBuilder()
8+
.AddConfiguration(out RemoveDummyGpCodeConfig config)
9+
.ConfigureFunctionsWorkerDefaults()
10+
.ConfigureServices(services =>
11+
{
12+
services.AddSingleton<ICreateResponse, CreateResponse>();
13+
services.AddBasicHealthCheck("RemoveDummyGPCode");
14+
})
15+
.AddTelemetry()
16+
.AddHttpClient()
17+
.AddServiceBusClient(config.ServiceBusConnectionString_client_internal)
18+
.Build();
19+
20+
await host.RunAsync();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"profiles": {
3+
"ManageServiceNowParticipant": {
4+
"commandName": "Project",
5+
"commandLineArgs": "--port 9092",
6+
"launchBrowser": false
7+
}
8+
}
9+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
namespace NHS.CohortManager.ParticipantManagementServices;
2+
3+
using System.ComponentModel.DataAnnotations;
4+
using System.Globalization;
5+
using System.Net;
6+
using System.Net.Http.Json;
7+
using System.Text;
8+
using System.Text.Json;
9+
using System.Text.RegularExpressions;
10+
using Common;
11+
using Microsoft.Azure.Functions.Worker;
12+
using Microsoft.Azure.Functions.Worker.Http;
13+
using Microsoft.Extensions.Logging;
14+
using Microsoft.Extensions.Options;
15+
using Model;
16+
using Model.Constants;
17+
using NHS.CohortManager.ParticipantManagementServices.Models;
18+
19+
public class ReceiveRemoveDummyGpCodeFunction
20+
{
21+
private readonly ILogger<ReceiveRemoveDummyGpCodeFunction> _logger;
22+
private readonly ICreateResponse _createResponse;
23+
private readonly IHttpClientFunction _httpClientFunction;
24+
private readonly IQueueClient _queueClient;
25+
private readonly RemoveDummyGpCodeConfig _config;
26+
27+
private static readonly Regex NonLetterRegex = new(@"[^\p{Lu}\p{Ll}\p{Lt}]", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
28+
29+
public ReceiveRemoveDummyGpCodeFunction(
30+
ILogger<ReceiveRemoveDummyGpCodeFunction> logger,
31+
ICreateResponse createResponse,
32+
IHttpClientFunction httpClientFunction,
33+
IQueueClient queueClient,
34+
IOptions<RemoveDummyGpCodeConfig> config)
35+
{
36+
_logger = logger;
37+
_createResponse = createResponse;
38+
_httpClientFunction = httpClientFunction;
39+
_queueClient = queueClient;
40+
_config = config.Value;
41+
}
42+
43+
/// <summary>
44+
/// Validates and enqueues a dummy GP code removal request to the ServiceNow participant management topic.
45+
/// </summary>
46+
[Function("ReceiveRemoveDummyGPCodeFunction")]
47+
public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "RemoveDummyGPCode")] HttpRequestData req)
48+
{
49+
try
50+
{
51+
var requestBody = await JsonSerializer.DeserializeAsync<RemoveDummyGPCodeRequestBody>(req.Body);
52+
if (requestBody == null)
53+
{
54+
_logger.LogError("Request body deserialised to null");
55+
return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req);
56+
}
57+
58+
var validationContext = new ValidationContext(requestBody);
59+
var validationResult = new List<ValidationResult>();
60+
var isRequestValid = Validator.TryValidateObject(requestBody, validationContext, validationResult, true);
61+
62+
if (!isRequestValid)
63+
{
64+
_logger.LogError("Request body failed validation");
65+
return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req);
66+
}
67+
68+
if (!ValidationHelper.ValidateNHSNumber(requestBody.NhsNumber))
69+
{
70+
return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req);
71+
}
72+
73+
var pdsResponse = await _httpClientFunction.SendGetResponse($"{_config.RetrievePdsDemographicURL}?nhsNumber={requestBody.NhsNumber}");
74+
75+
if (pdsResponse.StatusCode == HttpStatusCode.NotFound)
76+
{
77+
return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req);
78+
}
79+
80+
if (pdsResponse.StatusCode != HttpStatusCode.OK)
81+
{
82+
_logger.LogError("Unexpected PDS response status code {StatusCode}", pdsResponse.StatusCode);
83+
return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req);
84+
}
85+
86+
var pdsDemographic = await pdsResponse.Content.ReadFromJsonAsync<PdsDemographic>();
87+
if (pdsDemographic == null)
88+
{
89+
_logger.LogError("Failed to deserialize PDS demographic response");
90+
return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req);
91+
}
92+
93+
if (!CheckParticipantDataMatches(requestBody, pdsDemographic))
94+
{
95+
return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req);
96+
}
97+
98+
if (!DateOnly.TryParseExact(pdsDemographic.DateOfBirth, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var pdsDateOfBirth))
99+
{
100+
_logger.LogError("PDS demographic date of birth was missing or invalid for RequestId {RequestId}", requestBody.RequestId);
101+
return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req);
102+
}
103+
104+
var participant = new ServiceNowParticipant
105+
{
106+
ServiceNowCaseNumber = requestBody.RequestId,
107+
ScreeningId = 1,
108+
NhsNumber = long.Parse(requestBody.NhsNumber),
109+
FirstName = pdsDemographic.FirstName ?? string.Empty,
110+
FamilyName = pdsDemographic.FamilyName ?? string.Empty,
111+
DateOfBirth = pdsDateOfBirth,
112+
BsoCode = pdsDemographic.CurrentPosting ?? string.Empty,
113+
ReasonForAdding = ServiceNowReasonsForAdding.DummyGpCodeRemoval,
114+
RequiredGpCode = null
115+
};
116+
117+
var enqueueResult = await _queueClient.AddAsync(participant, _config.ServiceNowParticipantManagementTopic);
118+
if (!enqueueResult)
119+
{
120+
_logger.LogError("Failed to enqueue remove dummy GP code request for RequestId {RequestId}", requestBody.RequestId);
121+
return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req);
122+
}
123+
124+
return _createResponse.CreateHttpResponse(HttpStatusCode.Accepted, req);
125+
}
126+
catch (JsonException ex)
127+
{
128+
_logger.LogError(ex, "Failed to deserialize request body");
129+
return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req);
130+
}
131+
catch (Exception ex)
132+
{
133+
_logger.LogError(ex, "Unexpected error occurred in ReceiveRemoveDummyGPCodeFunction");
134+
return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req);
135+
}
136+
}
137+
138+
private static bool CheckParticipantDataMatches(RemoveDummyGPCodeRequestBody requestBody, PdsDemographic pdsDemographic)
139+
{
140+
return NormalizedNamesMatch(requestBody.Forename, pdsDemographic.FirstName) &&
141+
NormalizedNamesMatch(requestBody.Surname, pdsDemographic.FamilyName) &&
142+
requestBody.DateOfBirth.ToString("yyyy-MM-dd") == pdsDemographic.DateOfBirth;
143+
}
144+
145+
/// <summary>
146+
/// Normalizes and compares two name strings by removing accents, spaces, hyphens, and special characters.
147+
/// Converts accented characters to their base forms (É→E, Ñ→N, Ö→O) to match database storage behavior.
148+
/// </summary>
149+
/// <param name="name1">First name to compare</param>
150+
/// <param name="name2">Second name to compare</param>
151+
/// <returns>True if the normalized names match (case-insensitive), false otherwise</returns>
152+
private static bool NormalizedNamesMatch(string? name1, string? name2)
153+
{
154+
if (string.IsNullOrWhiteSpace(name1) && string.IsNullOrWhiteSpace(name2))
155+
{
156+
return true;
157+
}
158+
159+
if (string.IsNullOrWhiteSpace(name1) || string.IsNullOrWhiteSpace(name2))
160+
{
161+
return false;
162+
}
163+
164+
var normalized1 = NormalizeName(name1);
165+
var normalized2 = NormalizeName(name2);
166+
167+
if (string.IsNullOrEmpty(normalized1) || string.IsNullOrEmpty(normalized2))
168+
{
169+
return false;
170+
}
171+
172+
return string.Equals(normalized1, normalized2, StringComparison.OrdinalIgnoreCase);
173+
}
174+
175+
/// <summary>
176+
/// Normalizes a name by removing accents and all non-letter characters.
177+
/// This handles spaces, hyphens, apostrophes, and other punctuation.
178+
/// Accented characters like É, Ñ, Ö are converted to their base forms (E, N, O).
179+
/// Uses Unicode NFD normalization to decompose accents, then removes diacritical marks.
180+
/// </summary>
181+
/// <param name="name">The name to normalize</param>
182+
/// <returns>Normalized name containing only unaccented ASCII letters</returns>
183+
private static string NormalizeName(string name)
184+
{
185+
if (string.IsNullOrWhiteSpace(name))
186+
{
187+
return string.Empty;
188+
}
189+
190+
var trimmedName = name.Trim();
191+
var normalizedString = trimmedName.Normalize(NormalizationForm.FormD);
192+
var lettersOnlyString = NonLetterRegex.Replace(normalizedString, string.Empty);
193+
194+
return lettersOnlyString.Normalize(NormalizationForm.FormC);
195+
}
196+
}

0 commit comments

Comments
 (0)