Skip to content

Commit 4d55cd0

Browse files
authored
Appt 1471 - Bulk upload toggle soft deletion status sites (#1117)
# Description Update to the bulk upload endpoint to handle the soft deletion (and re-activation) of sites - If the value of `isDeleted` is null or non-existant for a site - set it to true - Otherwise flip the existing value thus soft deleting an active site or re-activating a soft deleted site Fixes # (issue) # Checklist: - [ ] My work is behind a feature toggle (if appropriate) - [ ] If my work is behind a feature toggle, I've added a full suite of tests for both the ON and OFF state - [x] The ticket number is in the Pull Request title, with format "APPT-XXX: My Title Here" - [ ] I have ran npm tsc / lint (in the future these will be ran automatically) - [x] My code generates no new .NET warnings (in the future these will be treated as errors) - [ ] If I've added a new Function, it is disabled in all but one of the terraform groups (e.g. http_functions) - [ ] If I've added a new Function, it has both unit and integration tests. Any request body validators have unit tests also - [x] If I've made UI changes, I've added appropriate Playwright and Jest tests - [ ] If I've added/updated an end-point, I've added the appropriate annotations and tested the Swagger documentation reflects the change
1 parent 9b9f504 commit 4d55cd0

17 files changed

Lines changed: 561 additions & 3 deletions

File tree

postman-collection/Bulk Import.postman_collection.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,46 @@
125125
}
126126
},
127127
"response": []
128+
},
129+
{
130+
"name": "site-status/import",
131+
"request": {
132+
"method": "POST",
133+
"header": [
134+
{
135+
"key": "Authorization",
136+
"value": "Bearer {{bearerToken}}",
137+
"type": "text"
138+
},
139+
{
140+
"key": "ClientId",
141+
"value": "{{clientId}}",
142+
"type": "text"
143+
}
144+
],
145+
"body": {
146+
"mode": "formdata",
147+
"formdata": [
148+
{
149+
"key": "file",
150+
"type": "file",
151+
"src": []
152+
}
153+
]
154+
},
155+
"url": {
156+
"raw": "{{baseUrl}}/api/site-status/import",
157+
"host": [
158+
"{{baseUrl}}"
159+
],
160+
"path": [
161+
"api",
162+
"site-status",
163+
"import"
164+
]
165+
}
166+
},
167+
"response": []
128168
}
129169
]
130170
}

src/api/Nhs.Appointments.Api/Factories/DataImportHandlerFactory.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public class DataImportHandlerFactory(IServiceProvider serviceProvider) : IDataI
1212
BulkImportType.ApiUser => serviceProvider.GetService<IApiUserDataImportHandler>(),
1313
BulkImportType.Site => serviceProvider.GetService<ISiteDataImportHandler>(),
1414
BulkImportType.User => serviceProvider.GetService<IUserDataImportHandler>(),
15+
BulkImportType.SiteStatus => serviceProvider.GetService<ISiteStatusDataImportHandler>(),
1516
_ => throw new NotSupportedException()
1617
};
1718
}

src/api/Nhs.Appointments.Api/FunctionConfigurationExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ public static IFunctionsWorkerApplicationBuilder ConfigureFunctionDependencies(
122122
.AddScoped<IMetricsRecorder, InMemoryMetricsRecorder>()
123123
.AddUserNotifications(configuration)
124124
.AddAutoMapper(typeof(CosmosAutoMapperProfile))
125-
.AddTransient<IAdminUserDataImportHandler, AdminUserDataImportHandler>();
125+
.AddTransient<IAdminUserDataImportHandler, AdminUserDataImportHandler>()
126+
.AddTransient<ISiteStatusDataImportHandler, SiteStatusDataImportHandler>();
126127

127128
var leaseManagerConnection = Environment.GetEnvironmentVariable("LEASE_MANAGER_CONNECTION");
128129
if (leaseManagerConnection == "local")

src/api/Nhs.Appointments.Core/BulkImport/IDataImportHandler.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ public interface IAdminUserDataImportHandler : IDataImportHandler { }
1111
public interface IApiUserDataImportHandler : IDataImportHandler { }
1212
public interface ISiteDataImportHandler : IDataImportHandler { }
1313
public interface IUserDataImportHandler : IDataImportHandler { }
14+
public interface ISiteStatusDataImportHandler : IDataImportHandler { }
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Microsoft.AspNetCore.Http;
2+
3+
namespace Nhs.Appointments.Core.BulkImport;
4+
public class SiteStatusDataImportHandler(ISiteService siteService) : ISiteStatusDataImportHandler
5+
{
6+
public async Task<IEnumerable<ReportItem>> ProcessFile(IFormFile inputFile)
7+
{
8+
var siteRows = new List<SiteStatusImportRow>();
9+
var processor = new CsvProcessor<SiteStatusImportRow, SiteStatusImportRowMap>(
10+
sr => Task.Run(() => siteRows.Add(sr)),
11+
sr => sr.Id,
12+
() => new SiteStatusImportRowMap()
13+
);
14+
using TextReader fileReader = new StreamReader(inputFile.OpenReadStream());
15+
var report = (await processor.ProcessFile(fileReader)).ToList();
16+
17+
var sites = await siteService.GetAllSites(includeDeleted: true);
18+
19+
var missingSites = FindMissingSites(siteRows, sites);
20+
if (missingSites.Count > 0)
21+
{
22+
report.AddRange(missingSites.Select(ms => new ReportItem(-1, ms.Name, false, $"Could not find existing site with name {ms.Name} and ID: {ms.Id}")));
23+
}
24+
25+
if (report.Any(r => !r.Success))
26+
{
27+
return report.Where(r => !r.Success);
28+
}
29+
30+
foreach (var row in siteRows)
31+
{
32+
try
33+
{
34+
var result = await siteService.ToggleSiteSoftDeletionAsync(row.Id);
35+
if (!result.Success)
36+
{
37+
report.Add(new ReportItem(-1, row.Name, false, $"Failed to update the soft deletion status of site with name: {row.Name} and ID: {row.Id}"));
38+
continue;
39+
}
40+
}
41+
catch (Exception ex)
42+
{
43+
report.Add(new ReportItem(-1, row.Name, false, ex.Message));
44+
}
45+
}
46+
47+
return report;
48+
}
49+
50+
public static List<SiteStatusImportRow> FindMissingSites(List<SiteStatusImportRow> siteRows, IEnumerable<Site> sites)
51+
{
52+
return [.. siteRows.Where(sr => !sites.Any(s => s.Id == sr.Id && s.Name.Equals(sr.Name, StringComparison.CurrentCultureIgnoreCase)))];
53+
}
54+
55+
public class SiteStatusImportRow
56+
{
57+
public string Id { get; set; }
58+
public string Name { get; set; }
59+
}
60+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using CsvHelper.Configuration;
2+
using static Nhs.Appointments.Core.BulkImport.SiteStatusDataImportHandler;
3+
4+
namespace Nhs.Appointments.Core.BulkImport;
5+
public class SiteStatusImportRowMap : ClassMap<SiteStatusImportRow>
6+
{
7+
public SiteStatusImportRowMap()
8+
{
9+
Map(m => m.Id).Convert(x =>
10+
{
11+
var site = x.Row.GetField<string>("Id");
12+
13+
if (!CsvFieldValidator.StringHasValue(site))
14+
throw new ArgumentException("Site ID must have a value.");
15+
16+
return !Guid.TryParse(site, out _)
17+
? throw new ArgumentException($"Invalid GUID string format for Site field: '{site}'")
18+
: site;
19+
});
20+
Map(m => m.Name).Convert(x =>
21+
{
22+
var name = x.Row.GetField<string>("Name");
23+
24+
if (!CsvFieldValidator.StringHasValue(name))
25+
throw new ArgumentException("Site name must have a value.");
26+
27+
return name;
28+
});
29+
}
30+
}

src/api/Nhs.Appointments.Core/Constants/BulkImportType.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ public static class BulkImportType
55
// TODO: Can apiUser be removed completely?
66
public const string ApiUser = "apiUser";
77
public const string Site = "site";
8+
public const string SiteStatus = "site-status";
89
public const string User = "user";
910
}

src/api/Nhs.Appointments.Core/ISiteStore.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ Task<OperationResult> SaveSiteAsync(string siteId, string odsCode, string name,
2424
Task<OperationResult> UpdateSiteStatusAsync(string siteId, SiteStatus status);
2525

2626
Task<IEnumerable<Site>> GetSitesInIcbAsync(string icb);
27+
Task<OperationResult> ToggleSiteSoftDeletionAsync(string siteId);
2728
}

src/api/Nhs.Appointments.Core/SiteService.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Task<OperationResult> SaveSiteAsync(string siteId, string odsCode, string name,
2828
Task<OperationResult> SetSiteStatus(string siteId, SiteStatus status);
2929
Task<IEnumerable<Site>> GetSitesInIcbAsync(string icb);
3030
Task<IEnumerable<SiteWithDistance>> QuerySitesAsync(SiteFilter[] filters, int maxRecords, bool ignoreCache);
31+
Task<OperationResult> ToggleSiteSoftDeletionAsync(string siteId);
3132
}
3233

3334
public class SiteService(
@@ -169,7 +170,7 @@ public async Task<IEnumerable<Site>> GetAllSites(bool includeDeleted = false, bo
169170
// whereas DisableSiteCache is a global setting affecting all uses
170171
if (ignoreCache || options.Value.DisableSiteCache)
171172
{
172-
return await siteStore.GetAllSites();
173+
return await siteStore.GetAllSites(includeDeleted);
173174
}
174175

175176
var sites = memoryCache.Get(options.Value.SiteCacheKey) as IEnumerable<Site>;
@@ -228,6 +229,22 @@ public async Task<OperationResult> SetSiteStatus(string siteId, SiteStatus statu
228229
public async Task<IEnumerable<Site>> GetSitesInIcbAsync(string icb)
229230
=> await siteStore.GetSitesInIcbAsync(icb);
230231

232+
public async Task<OperationResult> ToggleSiteSoftDeletionAsync(string siteId)
233+
{
234+
var result = await siteStore.ToggleSiteSoftDeletionAsync(siteId);
235+
if (!result.Success)
236+
{
237+
return result;
238+
}
239+
240+
if (!options.Value.DisableSiteCache && memoryCache.TryGetValue(options.Value.SiteCacheKey, out _))
241+
{
242+
memoryCache.Remove(options.Value.SiteCacheKey);
243+
}
244+
245+
return result;
246+
}
247+
231248
public async Task<IEnumerable<SiteWithDistance>> QuerySitesAsync(SiteFilter[] filters, int maxRecords, bool ignoreCache)
232249
{
233250
var sites = await GetAllSites(false, ignoreCache);

src/api/Nhs.Appointments.Persistance/SiteStore.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,23 @@ public async Task<OperationResult> UpdateSiteStatusAsync(string siteId, SiteStat
191191

192192
public async Task<IEnumerable<Site>> GetSitesInIcbAsync(string icb)
193193
=> await cosmosStore.RunQueryAsync<Site>(s => s.DocumentType == "site" && s.IntegratedCareBoard == icb);
194+
195+
public async Task<OperationResult> ToggleSiteSoftDeletionAsync(string siteId)
196+
{
197+
var originalDocument = await GetOrDefault(siteId);
198+
if (originalDocument is null)
199+
{
200+
return new OperationResult(false, $"The specified site: {siteId} was not found.");
201+
}
202+
203+
var docType = cosmosStore.GetDocumentType();
204+
205+
var patchOperation = originalDocument.isDeleted is null
206+
? PatchOperation.Add("/isDeleted", true)
207+
: PatchOperation.Replace("/isDeleted", !originalDocument.isDeleted);
208+
209+
await cosmosStore.PatchDocument(docType, siteId, [patchOperation]);
210+
return new OperationResult(true);
211+
212+
}
194213
}

0 commit comments

Comments
 (0)