diff --git a/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/AgentClosingNotificationResult.cs b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/AgentClosingNotificationResult.cs new file mode 100644 index 00000000..6c217594 --- /dev/null +++ b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/AgentClosingNotificationResult.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Eurofurence.App.Server.Services.Abstractions.ArtShow +{ + public class AgentClosingNotificationResult + { + public int AgentBadgeNo { get; set; } + public string AgentName { get; set; } + public string ArtistName { get; set; } + public decimal TotalCashAmount { get; set; } + public int ExhibitsSold { get; set; } + public int ExhibitsUnsold { get; set; } + public int ExhibitsToAuction { get; set; } + } +} \ No newline at end of file diff --git a/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IAgentClosingResultService.cs b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IAgentClosingResultService.cs index 205ffe51..85168e48 100644 --- a/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IAgentClosingResultService.cs +++ b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IAgentClosingResultService.cs @@ -6,8 +6,12 @@ namespace Eurofurence.App.Server.Services.Abstractions.ArtShow { public interface IAgentClosingResultService { - Task ImportAgentClosingResultLogAsync(TextReader logReader); + Task ImportAgentClosingResultLogAsync(TextReader logReader); Task ExecuteNotificationRunAsync(); + + Task> SimulateNotificationRunAsync(); + + Task DeleteUnprocessedImportRowsAsync(); } } diff --git a/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IItemActivityService.cs b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IItemActivityService.cs index 9799142a..cffe5b9c 100644 --- a/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IItemActivityService.cs +++ b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/IItemActivityService.cs @@ -6,9 +6,10 @@ namespace Eurofurence.App.Server.Services.Abstractions.ArtShow { public interface IItemActivityService { - Task ImportActivityLogAsync(TextReader logReader); + Task ImportActivityLogAsync(TextReader logReader); Task> SimulateNotificationRunAsync(); Task ExecuteNotificationRunAsync(); + Task DeleteUnprocessedImportRowsAsync(); } } \ No newline at end of file diff --git a/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/ImportResult.cs b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/ImportResult.cs new file mode 100644 index 00000000..ab89eb77 --- /dev/null +++ b/src/Eurofurence.App.Server.Services/Abstractions/ArtShow/ImportResult.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Eurofurence.App.Server.Services.Abstractions.ArtShow +{ + public class ImportResult + { + public int RowsImported { get; set; } + public int RowsSkippedAsDuplicate { get; set; } + } +} diff --git a/src/Eurofurence.App.Server.Services/ArtShow/AgentClosingResultService.cs b/src/Eurofurence.App.Server.Services/ArtShow/AgentClosingResultService.cs index b5816fb7..4f3a3591 100644 --- a/src/Eurofurence.App.Server.Services/ArtShow/AgentClosingResultService.cs +++ b/src/Eurofurence.App.Server.Services/ArtShow/AgentClosingResultService.cs @@ -33,24 +33,30 @@ IPrivateMessageService privateMessageService _privateMessageService = privateMessageService; } - public async Task ImportAgentClosingResultLogAsync(TextReader logReader) + public async Task ImportAgentClosingResultLogAsync(TextReader logReader) { + var importResult = new ImportResult(); + try { await _semaphore.WaitAsync(); - + var csv = new CsvReader(logReader, CultureInfo.CurrentCulture); csv.Configuration.RegisterClassMap(); csv.Configuration.Delimiter = ","; csv.Configuration.HasHeaderRecord = false; - var csvRecords = csv.GetRecords().ToList(); + var csvRecords = await csv.GetRecordsAsync().ToListAsync(); foreach (var csvRecord in csvRecords) { var existingRecord = await _agentClosingResultRepository.FindOneAsync(a => a.ImportHash == csvRecord.Hash.Value); - if (existingRecord != null) continue; + if (existingRecord != null) + { + importResult.RowsSkippedAsDuplicate++; + continue; + } var newRecord = new AgentClosingResultRecord() { @@ -69,21 +75,28 @@ public async Task ImportAgentClosingResultLogAsync(TextReader logReader) newRecord.Touch(); await _agentClosingResultRepository.InsertOneAsync(newRecord); + importResult.RowsImported++; } } finally { _semaphore.Release(); } + + return importResult; } + private Task> GetUnprocessedImportRows() + => _agentClosingResultRepository.FindAllAsync(a => a.NotificationDateTimeUtc == null); + + public async Task ExecuteNotificationRunAsync() { try { await _semaphore.WaitAsync(); - var newNotifications = await _agentClosingResultRepository.FindAllAsync(a => a.NotificationDateTimeUtc == null); + var newNotifications = await GetUnprocessedImportRows(); var tasks = newNotifications.Select(result => SendAgentClosingResultNotificationAsync(result)); @@ -123,5 +136,31 @@ private async Task SendAgentClosingResultNotificationAsync(AgentClosingResultRec await _agentClosingResultRepository.ReplaceOneAsync(result); } + + public async Task> SimulateNotificationRunAsync() + { + var newNotifications = await GetUnprocessedImportRows(); + + return newNotifications + .Select(item => new AgentClosingNotificationResult() + { + AgentBadgeNo = item.AgentBadgeNo, + AgentName = item.AgentName, + ArtistName = item.ArtistName, + ExhibitsSold = item.ExhibitsSold, + ExhibitsUnsold = item.ExhibitsUnsold, + ExhibitsToAuction = item.ExhibitsToAuction + }) + .ToList(); + + } + + public async Task DeleteUnprocessedImportRowsAsync() + { + var recordsToDelete = await GetUnprocessedImportRows(); + + foreach (var record in recordsToDelete) + await _agentClosingResultRepository.DeleteOneAsync(record.Id); + } } } \ No newline at end of file diff --git a/src/Eurofurence.App.Server.Services/ArtShow/ItemActivityService.cs b/src/Eurofurence.App.Server.Services/ArtShow/ItemActivityService.cs index bb448c6c..b91eeab3 100644 --- a/src/Eurofurence.App.Server.Services/ArtShow/ItemActivityService.cs +++ b/src/Eurofurence.App.Server.Services/ArtShow/ItemActivityService.cs @@ -33,8 +33,9 @@ IPrivateMessageService privateMessageService _privateMessageService = privateMessageService; } - public async Task ImportActivityLogAsync(TextReader logReader) + public async Task ImportActivityLogAsync(TextReader logReader) { + var importResult = new ImportResult(); var csv = new CsvReader(logReader, CultureInfo.CurrentCulture); csv.Configuration.RegisterClassMap(); @@ -46,7 +47,11 @@ public async Task ImportActivityLogAsync(TextReader logReader) foreach (var csvRecord in csvRecords) { var existingRecord = await _itemActivityRepository.FindOneAsync(a => a.ImportHash == csvRecord.Hash.Value); - if (existingRecord != null) continue; + if (existingRecord != null) + { + importResult.RowsSkippedAsDuplicate++; + continue; + } var newRecord = new ItemActivityRecord() { @@ -63,9 +68,14 @@ public async Task ImportActivityLogAsync(TextReader logReader) newRecord.Touch(); await _itemActivityRepository.InsertOneAsync(newRecord); + importResult.RowsImported++; } + + return importResult; } + private Task> GetUnprocessedImportRows() + => _itemActivityRepository.FindAllAsync(a => a.NotificationDateTimeUtc == null); private class NotificationBundle { @@ -76,7 +86,7 @@ private class NotificationBundle private async Task> BuildNotificationBundlesAsync() { - var newActivities = await _itemActivityRepository.FindAllAsync(a => a.NotificationDateTimeUtc == null); + var newActivities = await GetUnprocessedImportRows(); return newActivities .GroupBy(a => a.OwnerUid) @@ -192,5 +202,13 @@ public async Task> SimulateNotificationRun }) .ToList(); } + + public async Task DeleteUnprocessedImportRowsAsync() + { + var recordsToDelete = await GetUnprocessedImportRows(); + + foreach (var record in recordsToDelete) + await _itemActivityRepository.DeleteOneAsync(record.Id); + } } } diff --git a/src/Eurofurence.App.Server.Services/Eurofurence.App.Server.Services.csproj b/src/Eurofurence.App.Server.Services/Eurofurence.App.Server.Services.csproj index b8804f06..b23fcf34 100644 --- a/src/Eurofurence.App.Server.Services/Eurofurence.App.Server.Services.csproj +++ b/src/Eurofurence.App.Server.Services/Eurofurence.App.Server.Services.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Eurofurence.App.Server.Web/Controllers/ArtShowController.cs b/src/Eurofurence.App.Server.Web/Controllers/ArtShowController.cs index 9f8f0086..2a2c0b3d 100644 --- a/src/Eurofurence.App.Server.Web/Controllers/ArtShowController.cs +++ b/src/Eurofurence.App.Server.Web/Controllers/ArtShowController.cs @@ -27,50 +27,141 @@ IApiPrincipal apiPrincipal _apiPrincipal = apiPrincipal; } - [Authorize(Roles = "System,Developer")] + /// + /// Import Art Show results for bidders (CSV) + /// + /// + /// Imports the Art Show result CSV for bidders (`RegNo, ASIDNO, ArtistName, ArtPieceTitle, Status ["Sold", Auction"]`).

+ /// There's a row-hash built from the mentioned import fields. If a row with the same hash is already present, it will be skipped. + /// (Importing the same data multiple times does not lead to duplicates.) + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] [HttpPost("ItemActivites/Log")] [ProducesResponseType(201)] [BinaryPayload(Description = "Art show log contents")] - public async Task ImportActivityLogAsync() + public Task ImportActivityLogAsync() { - await _itemActivityService.ImportActivityLogAsync(new StreamReader(Request.Body)); - return NoContent(); + return _itemActivityService.ImportActivityLogAsync(new StreamReader(Request.Body)); } - [Authorize(Roles = "System,Developer")] + /// + /// Simulate notification bundle generation for bidders. + /// + /// + /// Simulates building notification bundles that group all item results (sold, to auction) by the bidder who holds the winning/highest bid.

+ /// The simulation data shows individual notifications for every bidder (`RecipientUid`), listing the items they won (`IdsSold`) + /// as well as items going into auction (`IdsToAuction`) with their current bid on top.

+ /// Calling this endpoint does not modify any data and does not send any messages. + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] [HttpGet("ItemActivites/NotificationBundles/Simulation")] [ProducesResponseType(200)] - public Task> SimulateNotificationRunAsync() + public Task> SimulateItemActivitiesNotificationRunAsync() { return _itemActivityService.SimulateNotificationRunAsync(); } - [Authorize(Roles = "System,Developer")] + + /// + /// Execute/Send notification bundles for bidders. + /// + /// + /// Builds notification bundles that group all item results (sold, to auction) by the bidder who holds the winning/highest bid.

+ /// Calling this endpoint will produce and queue all notifications/messages for delivery. Notifications are sent asynchronously and in queue, + /// so full delivery may take a few minutes. + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] [HttpPost("ItemActivites/NotificationBundles/Send")] - [ProducesResponseType(200)] + [ProducesResponseType(204)] public async Task ExecuteNotificationRunAsync() { await _itemActivityService.ExecuteNotificationRunAsync(); return NoContent(); } - [Authorize(Roles = "System,Developer")] + + /// + /// Delete unprocessed rows + /// + /// + /// Deletes any imported data for which no notification has been sent yet.

+ /// Use this to remove imported data if the simulation does not check out. + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] + [HttpDelete("ItemActivites/NotificationBundles/:unprocessed")] + [ProducesResponseType(204)] + public async Task DeleteUnprocessedItemActivitiesImportRowsAsync() + { + await _itemActivityService.DeleteUnprocessedImportRowsAsync(); + return NoContent(); + } + + + /// + /// Import Art Show results for agents (CSV) + /// + /// + /// Imports the Art Show result CSV for agents (`AgentBadgeNo, AgentName, ArtistName, TotalCashAmount, ExhibitsSold, ExhibitsUnsold, ExhibitsToAuction`).

+ /// There's a row-hash built from the mentioned import fields. If a row with the same hash is already present, it will be skipped. + /// (Importing the same data multiple times does not lead to duplicates.) + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] [HttpPost("AgentClosingResults/Log")] [ProducesResponseType(201)] [BinaryPayload(Description = "Agent closing result log contents")] - public async Task ImportAgentClosingResultLogAsync() + public Task ImportAgentClosingResultLogAsync() { - await _agentClosingResultService.ImportAgentClosingResultLogAsync(new StreamReader(Request.Body)); - return NoContent(); + return _agentClosingResultService.ImportAgentClosingResultLogAsync(new StreamReader(Request.Body)); } - [Authorize(Roles = "System,Developer")] - [HttpPost("AgentClosingResults/NotificationBundles/Send")] + /// + /// Simulate notification bundle generation for agents. + /// + /// + /// Simulates building notification going towards agents.

+ /// The simulation data shows individual notification data for every agent (`AgentBadgeNo`).

+ /// Calling this endpoint does not modify any data and does not send any messages. + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] + [HttpGet("AgentClosingResults/NotificationBundles/Simulation")] [ProducesResponseType(200)] + public Task> SimulateAgentClosingResultsNotificationRunAsync() + { + return _agentClosingResultService.SimulateNotificationRunAsync(); + } + + + /// + /// Execute/Send notification bundles for agents. + /// + /// + /// Builds notifications for all agents.

+ /// Calling this endpoint will produce and queue all notifications/messages for delivery. Notifications are sent asynchronously and in queue, + /// so full delivery may take a few minutes. + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] + [HttpPost("AgentClosingResults/NotificationBundles/Send")] + [ProducesResponseType(204)] public async Task ExecuteAgentNotificationRunAsync() { await _agentClosingResultService.ExecuteNotificationRunAsync(); return NoContent(); } + + /// + /// Delete unprocessed rows + /// + /// + /// Deletes any imported data for which no notification has been sent yet.

+ /// Use this to remove imported data if the simulation does not check out. + ///
+ [Authorize(Roles = "System,Developer,ArtShow")] + [HttpDelete("AgentClosingResults/NotificationBundles/:unprocessed")] + [ProducesResponseType(204)] + public async Task DeleteUnprocessedAgentClosingResultsImportRowsAsync() + { + await _agentClosingResultService.DeleteUnprocessedImportRowsAsync(); + return NoContent(); + } } } \ No newline at end of file