diff --git a/.github/workflows/dependabot_automerge.yml b/.github/workflows/dependabot_automerge.yml index eb912398..5132852e 100644 --- a/.github/workflows/dependabot_automerge.yml +++ b/.github/workflows/dependabot_automerge.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2.2.0 + uses: dependabot/fetch-metadata@v2.3.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs diff --git a/PathfinderHonorManager.Tests/PathfinderHonorManager.Tests.csproj b/PathfinderHonorManager.Tests/PathfinderHonorManager.Tests.csproj index 95c90ea3..59e5a5d5 100644 --- a/PathfinderHonorManager.Tests/PathfinderHonorManager.Tests.csproj +++ b/PathfinderHonorManager.Tests/PathfinderHonorManager.Tests.csproj @@ -8,20 +8,20 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + + - + diff --git a/PathfinderHonorManager/Controllers/ClubController.cs b/PathfinderHonorManager/Controllers/ClubController.cs index 93cd84d9..331a9c88 100644 --- a/PathfinderHonorManager/Controllers/ClubController.cs +++ b/PathfinderHonorManager/Controllers/ClubController.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc; using PathfinderHonorManager.Model; using PathfinderHonorManager.Service.Interfaces; +using Microsoft.Extensions.Logging; +using System.Linq; namespace PathfinderHonorManager.Controllers { @@ -20,10 +22,12 @@ namespace PathfinderHonorManager.Controllers public class ClubsController : ControllerBase { private readonly IClubService _clubService; + private readonly ILogger _logger; - public ClubsController(IClubService clubService) + public ClubsController(IClubService clubService, ILogger logger) { _clubService = clubService; + _logger = logger; } // GET Clubs @@ -39,24 +43,30 @@ public async Task>> GetClubs(CancellationToken to { if (clubcode == null) { + _logger.LogInformation("Getting all clubs"); var clubs = await _clubService.GetAllAsync(token); if (clubs == default) { + _logger.LogWarning("No clubs found"); return NotFound(); } + _logger.LogInformation("Retrieved {Count} clubs", clubs.Count()); return Ok(clubs); } else { + _logger.LogInformation("Getting club with code {ClubCode}", clubcode); var club = await _clubService.GetByCodeAsync(clubcode.ToUpper(), token); if (club == default) { + _logger.LogWarning("Club with code {ClubCode} not found", clubcode); return NotFound(); } + _logger.LogInformation("Retrieved club with code {ClubCode}", clubcode); return Ok(club); } } @@ -73,13 +83,16 @@ public async Task>> GetClubs(CancellationToken to [HttpGet("{id:guid}")] public async Task GetByIdAsync(Guid id, CancellationToken token) { + _logger.LogInformation("Getting club with ID {ClubId}", id); var club = await _clubService.GetByIdAsync(id, token); if (club == default) { + _logger.LogWarning("Club with ID {ClubId} not found", id); return NotFound(); } + _logger.LogInformation("Retrieved club with ID {ClubId}", id); return Ok(club); } } diff --git a/PathfinderHonorManager/Controllers/HonorsController.cs b/PathfinderHonorManager/Controllers/HonorsController.cs index 7f9034cd..0f88972a 100644 --- a/PathfinderHonorManager/Controllers/HonorsController.cs +++ b/PathfinderHonorManager/Controllers/HonorsController.cs @@ -9,6 +9,8 @@ using PathfinderHonorManager.Service.Interfaces; using Incoming = PathfinderHonorManager.Dto.Incoming; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using System.Linq; namespace PathfinderHonorManager.Controllers { @@ -22,10 +24,12 @@ namespace PathfinderHonorManager.Controllers public class HonorsController : CustomApiController { private readonly IHonorService _honorService; + private readonly ILogger _logger; - public HonorsController(IHonorService honorService) + public HonorsController(IHonorService honorService, ILogger logger) { _honorService = honorService; + _logger = logger; } // GET Honors @@ -38,13 +42,16 @@ public HonorsController(IHonorService honorService) [HttpGet] public async Task>> GetHonors(CancellationToken token) { + _logger.LogInformation("Getting all honors"); var honors = await this._honorService.GetAllAsync(token); if (honors == default) { + _logger.LogWarning("No honors found"); return NotFound(); } + _logger.LogInformation("Retrieved {Count} honors", honors.Count()); return Ok(honors); } @@ -60,13 +67,16 @@ public async Task>> GetHonors(CancellationToken [HttpGet("{id:guid}")] public async Task GetByIdAsync(Guid id, CancellationToken token) { + _logger.LogInformation("Getting honor with ID {HonorId}", id); var honor = await this._honorService.GetByIdAsync(id, token); if (honor == default) { + _logger.LogWarning("Honor with ID {HonorId} not found", id); return NotFound(); } + _logger.LogInformation("Retrieved honor with ID {HonorId}", id); return Ok(new { id = honor.HonorID, honor }); } @@ -83,21 +93,25 @@ public async Task GetByIdAsync(Guid id, CancellationToken token) [HttpPost] public async Task> Post([FromBody] Incoming.HonorDto newHonor, CancellationToken token) { + _logger.LogInformation("Creating new honor"); try { var honor = await _honorService.AddAsync(newHonor, token); + _logger.LogInformation("Created honor with ID {HonorId}", honor.HonorID); return CreatedAtRoute( routeValues: GetByIdAsync(honor.HonorID, token), honor); } catch (FluentValidation.ValidationException ex) { + _logger.LogWarning(ex, "Validation failed while creating honor"); UpdateModelState(ex); return ValidationProblem(ModelState); } catch (DbUpdateException ex) { + _logger.LogError(ex, "Database error while creating honor"); return ValidationProblem(ex.Message); } } @@ -116,20 +130,37 @@ public async Task> Post([FromBody] Incoming.HonorDto newHono [HttpPut("{id:guid}")] public async Task Put(Guid id, [FromBody] Incoming.HonorDto updatedHonor, CancellationToken token) { + _logger.LogInformation("Updating honor with ID {HonorId}", id); var honor = await _honorService.GetByIdAsync(id, token); if (honor == default) { + _logger.LogWarning("Honor with ID {HonorId} not found", id); return NotFound(); } - await _honorService.UpdateAsync(id, updatedHonor, token); + try + { + await _honorService.UpdateAsync(id, updatedHonor, token); - honor = await _honorService.GetByIdAsync(id, token); + honor = await _honorService.GetByIdAsync(id, token); + _logger.LogInformation("Updated honor with ID {HonorId}", id); - return honor != default - ? Ok(honor) - : NotFound(); + return honor != default + ? Ok(honor) + : NotFound(); + } + catch (FluentValidation.ValidationException ex) + { + _logger.LogWarning(ex, "Validation failed while updating honor with ID {HonorId}", id); + UpdateModelState(ex); + return ValidationProblem(ModelState); + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "Database error while updating honor with ID {HonorId}", id); + return ValidationProblem(ex.Message); + } } } } diff --git a/PathfinderHonorManager/Controllers/PathfinderController.cs b/PathfinderHonorManager/Controllers/PathfinderController.cs index 5e2ee064..8c0a94b1 100644 --- a/PathfinderHonorManager/Controllers/PathfinderController.cs +++ b/PathfinderHonorManager/Controllers/PathfinderController.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authorization; using System.Linq; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; namespace PathfinderHonorManager.Controllers { @@ -24,6 +25,7 @@ namespace PathfinderHonorManager.Controllers public class PathfindersController : CustomApiController { private readonly IPathfinderService _pathfinderService; + private readonly ILogger _logger; private string GetClubCodeFromContext() { @@ -31,9 +33,10 @@ private string GetClubCodeFromContext() return clubCode; } - public PathfindersController(IPathfinderService pathfinderService) + public PathfindersController(IPathfinderService pathfinderService, ILogger logger) { _pathfinderService = pathfinderService; + _logger = logger; } // GET Pathfinders @@ -48,13 +51,17 @@ public PathfindersController(IPathfinderService pathfinderService) public async Task>> GetAll(CancellationToken token, bool showInactive = false) { var clubCode = GetClubCodeFromContext(); + _logger.LogInformation("Getting all pathfinders for club {ClubCode}, showInactive: {ShowInactive}", clubCode, showInactive); + var pathfinders = await _pathfinderService.GetAllAsync(clubCode, showInactive, token); if (pathfinders == null || !pathfinders.Any()) { + _logger.LogWarning("No pathfinders found for club {ClubCode}", clubCode); return NotFound(); } + _logger.LogInformation("Retrieved {Count} pathfinders for club {ClubCode}", pathfinders.Count(), clubCode); return Ok(pathfinders); } @@ -71,13 +78,17 @@ public PathfindersController(IPathfinderService pathfinderService) public async Task GetByIdAsync(Guid id, CancellationToken token) { var clubCode = GetClubCodeFromContext(); + _logger.LogInformation("Getting pathfinder with ID {PathfinderId} for club {ClubCode}", id, clubCode); + var pathfinder = await _pathfinderService.GetByIdAsync(id, clubCode, token); if (pathfinder == default) { + _logger.LogWarning("Pathfinder with ID {PathfinderId} not found for club {ClubCode}", id, clubCode); return NotFound(); } + _logger.LogInformation("Retrieved pathfinder with ID {PathfinderId} for club {ClubCode}", id, clubCode); return Ok(pathfinder); } @@ -95,24 +106,28 @@ public async Task GetByIdAsync(Guid id, CancellationToken token) public async Task PostAsync([FromBody] Incoming.PathfinderDto newPathfinder, CancellationToken token) { var clubCode = GetClubCodeFromContext(); + _logger.LogInformation("Creating new pathfinder for club {ClubCode}", clubCode); + try { var pathfinder = await _pathfinderService.AddAsync(newPathfinder, clubCode, token); + _logger.LogInformation("Created pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinder.PathfinderID, clubCode); return CreatedAtRoute( routeValues: GetByIdAsync(pathfinder.PathfinderID, token), pathfinder); } catch (FluentValidation.ValidationException ex) { + _logger.LogWarning(ex, "Validation failed while creating pathfinder for club {ClubCode}", clubCode); UpdateModelState(ex); return ValidationProblem(ModelState); } catch (DbUpdateException ex) { + _logger.LogError(ex, "Database error while creating pathfinder for club {ClubCode}", clubCode); return ValidationProblem(ex.Message); } - } // PUT Pathfinders/{pathfinderId} @@ -130,21 +145,30 @@ public async Task PostAsync([FromBody] Incoming.PathfinderDto new public async Task PutAsync(Guid pathfinderId, [FromBody] Incoming.PutPathfinderDto updatedPathfinder, CancellationToken token) { var clubCode = GetClubCodeFromContext(); + _logger.LogInformation("Updating pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinderId, clubCode); + try { var pathfinder = await _pathfinderService.UpdateAsync(pathfinderId, updatedPathfinder, clubCode, token); - return pathfinder != default - ? Ok(pathfinder) - : NotFound(); + if (pathfinder == default) + { + _logger.LogWarning("Pathfinder with ID {PathfinderId} not found for club {ClubCode}", pathfinderId, clubCode); + return NotFound(); + } + + _logger.LogInformation("Updated pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinderId, clubCode); + return Ok(pathfinder); } catch (FluentValidation.ValidationException ex) { + _logger.LogWarning(ex, "Validation failed while updating pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinderId, clubCode); UpdateModelState(ex); return ValidationProblem(ModelState); } catch (DbUpdateException ex) { + _logger.LogError(ex, "Database error while updating pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinderId, clubCode); return ValidationProblem(ex.Message); } } @@ -163,6 +187,8 @@ public async Task PutAsync(Guid pathfinderId, [FromBody] Incoming public async Task BulkPutPathfindersAsync([FromBody] IEnumerable bulkData, CancellationToken token) { var clubCode = GetClubCodeFromContext(); + _logger.LogInformation("Bulk updating {Count} pathfinders for club {ClubCode}", bulkData.Count(), clubCode); + var responses = new List(); foreach (var data in bulkData) @@ -178,9 +204,19 @@ public async Task BulkPutPathfindersAsync([FromBody] IEnumerable< status = pathfinder != null ? StatusCodes.Status200OK : StatusCodes.Status404NotFound, pathfinderId = item.PathfinderId, }); + + if (pathfinder == null) + { + _logger.LogWarning("Pathfinder with ID {PathfinderId} not found during bulk update for club {ClubCode}", item.PathfinderId, clubCode); + } + else + { + _logger.LogInformation("Updated pathfinder with ID {PathfinderId} during bulk update for club {ClubCode}", item.PathfinderId, clubCode); + } } catch (FluentValidation.ValidationException ex) { + _logger.LogWarning(ex, "Validation failed while bulk updating pathfinder with ID {PathfinderId} for club {ClubCode}", item.PathfinderId, clubCode); responses.Add(new { status = StatusCodes.Status400BadRequest, @@ -190,6 +226,7 @@ public async Task BulkPutPathfindersAsync([FromBody] IEnumerable< } catch (DbUpdateException ex) { + _logger.LogError(ex, "Database error while bulk updating pathfinder with ID {PathfinderId} for club {ClubCode}", item.PathfinderId, clubCode); responses.Add(new { status = StatusCodes.Status400BadRequest, @@ -200,6 +237,7 @@ public async Task BulkPutPathfindersAsync([FromBody] IEnumerable< } } + _logger.LogInformation("Completed bulk update of {Count} pathfinders for club {ClubCode}", bulkData.Count(), clubCode); return StatusCode(StatusCodes.Status207MultiStatus, responses); } } diff --git a/PathfinderHonorManager/Converters/NullableDateTimeConverter.cs b/PathfinderHonorManager/Converters/NullableDateTimeConverter.cs index 61b79907..038a9ca0 100644 --- a/PathfinderHonorManager/Converters/NullableDateTimeConverter.cs +++ b/PathfinderHonorManager/Converters/NullableDateTimeConverter.cs @@ -1,6 +1,7 @@ using System; using System.Text.Json; using System.Text.Json.Serialization; +using System.Globalization; namespace PathfinderHonorManager.Converters { @@ -8,7 +9,7 @@ public class NullableDateTimeConverter : JsonConverter { public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - return reader.GetString() == null ? (DateTime?)null : DateTime.Parse(reader.GetString()); + return reader.GetString() == null ? (DateTime?)null : DateTime.Parse(reader.GetString(), CultureInfo.InvariantCulture); } public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) diff --git a/PathfinderHonorManager/Dto/Incoming/ClubDto.cs b/PathfinderHonorManager/Dto/Incoming/ClubDto.cs new file mode 100644 index 00000000..db2d869a --- /dev/null +++ b/PathfinderHonorManager/Dto/Incoming/ClubDto.cs @@ -0,0 +1,14 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace PathfinderHonorManager.Dto.Incoming +{ + public class ClubDto + { + [Required] + public string Name { get; set; } + + [Required] + public string ClubCode { get; set; } + } +} \ No newline at end of file diff --git a/PathfinderHonorManager/PathfinderHonorManager.csproj b/PathfinderHonorManager/PathfinderHonorManager.csproj index 1dc416e2..929a9079 100644 --- a/PathfinderHonorManager/PathfinderHonorManager.csproj +++ b/PathfinderHonorManager/PathfinderHonorManager.csproj @@ -11,24 +11,24 @@ - - - + + + - - + + - - + + - - - - - - + + + + + + diff --git a/PathfinderHonorManager/Program.cs b/PathfinderHonorManager/Program.cs index 5620b09c..1d417fb8 100644 --- a/PathfinderHonorManager/Program.cs +++ b/PathfinderHonorManager/Program.cs @@ -3,10 +3,11 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.AzureAppServices; +using Microsoft.Extensions.Configuration; namespace PathfinderHonorManager { - public class Program + public static class Program { public static void Main(string[] args) { @@ -19,8 +20,14 @@ public static IHostBuilder CreateHostBuilder(string[] args) => { webBuilder.UseStartup(); }) - .ConfigureLogging(logging => - logging.AddAzureWebAppDiagnostics()) + .ConfigureLogging((hostingContext, logging) => + { + logging.ClearProviders(); + logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging")); + logging.AddConsole(); + logging.AddDebug(); + logging.AddAzureWebAppDiagnostics(); + }) .ConfigureServices(services => services.Configure(options => { diff --git a/PathfinderHonorManager/Service/ClubService.cs b/PathfinderHonorManager/Service/ClubService.cs index f9101e23..7f7d6b15 100644 --- a/PathfinderHonorManager/Service/ClubService.cs +++ b/PathfinderHonorManager/Service/ClubService.cs @@ -33,38 +33,51 @@ public ClubService( public async Task> GetAllAsync(CancellationToken token) { + _logger.LogInformation("Retrieving all clubs"); var clubs = await _dbContext.Clubs .OrderBy(h => h.Name) .ToListAsync(token); + _logger.LogInformation("Retrieved {Count} clubs", clubs.Count); return _mapper.Map>(clubs); - } public async Task GetByIdAsync(Guid id, CancellationToken token) { + _logger.LogInformation("Retrieving club with ID: {ClubId}", id); Club entity; entity = await _dbContext.Clubs .SingleOrDefaultAsync(p => p.ClubID == id, token); - return entity == default - ? default - : _mapper.Map(entity); + if (entity == default) + { + _logger.LogWarning("Club with ID {ClubId} not found", id); + return default; + } + + _logger.LogInformation("Retrieved club: {ClubName} (ID: {ClubId})", entity.Name, id); + return _mapper.Map(entity); } public async Task GetByCodeAsync(string code, CancellationToken token) { code = code.ToUpper(); + _logger.LogInformation("Retrieving club with code: {ClubCode}", code); Club entity; entity = await _dbContext.Clubs .SingleOrDefaultAsync(p => p.ClubCode == code, token); - return entity == default - ? default - : _mapper.Map(entity); + if (entity == default) + { + _logger.LogWarning("Club with code {ClubCode} not found", code); + return default; + } + + _logger.LogInformation("Retrieved club: {ClubName} (Code: {ClubCode})", entity.Name, code); + return _mapper.Map(entity); } } } diff --git a/PathfinderHonorManager/Service/HonorService.cs b/PathfinderHonorManager/Service/HonorService.cs index dec162ed..6a770a19 100644 --- a/PathfinderHonorManager/Service/HonorService.cs +++ b/PathfinderHonorManager/Service/HonorService.cs @@ -21,7 +21,7 @@ public class HonorService : IHonorService private readonly IMapper _mapper; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IValidator _validator; @@ -30,7 +30,7 @@ public HonorService( PathfinderContext context, IMapper mapper, IValidator validator, - ILogger logger) + ILogger logger) { _dbContext = context; _mapper = mapper; @@ -40,21 +40,30 @@ public HonorService( public async Task> GetAllAsync(CancellationToken token) { + _logger.LogInformation("Getting all honors"); var honors = await _dbContext.Honors .OrderBy(h => h.Name).ThenBy(h => h.Level) .ToListAsync(token); + _logger.LogInformation("Retrieved {Count} honors", honors.Count); return _mapper.Map>(honors); - } public async Task GetByIdAsync(Guid id, CancellationToken token) { - Honor entity; - - entity = await _dbContext.Honors + _logger.LogInformation("Getting honor with ID {HonorId}", id); + Honor entity = await _dbContext.Honors .SingleOrDefaultAsync(p => p.HonorID == id, token); + if (entity == default) + { + _logger.LogWarning("Honor with ID {HonorId} not found", id); + } + else + { + _logger.LogInformation("Retrieved honor with ID {HonorId}", id); + } + return entity == default ? default : _mapper.Map(entity); @@ -62,41 +71,61 @@ public HonorService( public async Task AddAsync(Incoming.HonorDto newHonor, CancellationToken token) { + _logger.LogInformation("Adding new honor"); + try + { + await _validator.ValidateAsync( + newHonor, + opt => opt + .ThrowOnFailures() + .IncludeAllRuleSets(), + token); - _ = await _validator.ValidateAsync( - newHonor, - opt => opt - .ThrowOnFailures() - .IncludeAllRuleSets(), - token); - - var honor = _mapper.Map(newHonor); + var honor = _mapper.Map(newHonor); - _dbContext.Honors.Add(honor); - await _dbContext.SaveChangesAsync(token); + _dbContext.Honors.Add(honor); + await _dbContext.SaveChangesAsync(token); + _logger.LogInformation("Added honor with ID {HonorId}", honor.HonorID); - return _mapper.Map(honor); + return _mapper.Map(honor); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding honor"); + throw; + } } public async Task UpdateAsync(Guid id, Incoming.HonorDto updatedHonor, CancellationToken token) { - _ = await _validator.ValidateAsync(updatedHonor, opt => opt.ThrowOnFailures(), token); + _logger.LogInformation("Updating honor with ID {HonorId}", id); + try + { + await _validator.ValidateAsync(updatedHonor, opt => opt.ThrowOnFailures(), token); - var existingHonor = await GetByIdAsync(id, token); + var existingHonor = await GetByIdAsync(id, token); - if (existingHonor == null) - { - return null; - } + if (existingHonor == null) + { + _logger.LogWarning("Honor with ID {HonorId} not found", id); + return null; + } - existingHonor.Name = updatedHonor.Name; - existingHonor.Level = updatedHonor.Level; - existingHonor.PatchFilename = updatedHonor.PatchFilename; - existingHonor.WikiPath = updatedHonor.WikiPath; + existingHonor.Name = updatedHonor.Name; + existingHonor.Level = updatedHonor.Level; + existingHonor.PatchFilename = updatedHonor.PatchFilename; + existingHonor.WikiPath = updatedHonor.WikiPath; - await _dbContext.SaveChangesAsync(token); + await _dbContext.SaveChangesAsync(token); + _logger.LogInformation("Updated honor with ID {HonorId}", id); - return _mapper.Map(existingHonor); + return _mapper.Map(existingHonor); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating honor with ID {HonorId}", id); + throw; + } } } diff --git a/PathfinderHonorManager/Service/PathfinderHonorService.cs b/PathfinderHonorManager/Service/PathfinderHonorService.cs index 9ae630d7..a15725f9 100644 --- a/PathfinderHonorManager/Service/PathfinderHonorService.cs +++ b/PathfinderHonorManager/Service/PathfinderHonorService.cs @@ -22,7 +22,7 @@ public class PathfinderHonorService : IPathfinderHonorService private readonly IMapper _mapper; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IValidator _validator; @@ -42,6 +42,7 @@ public PathfinderHonorService( public async Task> GetAllAsync(Guid pathfinderId, CancellationToken token) { + _logger.LogInformation("Getting all honors for pathfinder with ID {PathfinderId}", pathfinderId); var pathfinderhonors = await _dbContext.PathfinderHonors .Include(phs => phs.PathfinderHonorStatus) .Include(h => h.Honor) @@ -49,14 +50,17 @@ public PathfinderHonorService( .OrderBy(ph => ph.Honor.Name) .ToListAsync(token); + _logger.LogInformation("Retrieved {Count} honors for pathfinder with ID {PathfinderId}", pathfinderhonors.Count, pathfinderId); return _mapper.Map>(pathfinderhonors); - } public async Task> GetAllByStatusAsync(string honorStatus, CancellationToken token) { + _logger.LogInformation("Getting all honors with status {HonorStatus}", honorStatus); + if (string.IsNullOrWhiteSpace(honorStatus)) { + _logger.LogWarning("Empty honor status provided"); return new List(); } @@ -67,18 +71,28 @@ public PathfinderHonorService( .OrderBy(ph => ph.Honor.Name) .ToListAsync(token); + _logger.LogInformation("Retrieved {Count} honors with status {HonorStatus}", pathfinderhonors.Count, honorStatus); return _mapper.Map>(pathfinderhonors); } public async Task GetByIdAsync(Guid pathfinderId, Guid honorId, CancellationToken token) { - PathfinderHonor entity; - - entity = await GetFilteredPathfinderHonors(pathfinderId, honorId, token) + _logger.LogInformation("Getting honor with ID {HonorId} for pathfinder with ID {PathfinderId}", honorId, pathfinderId); + + PathfinderHonor entity = await GetFilteredPathfinderHonors(pathfinderId, honorId, token) .Include(phs => phs.PathfinderHonorStatus) .Include(h => h.Honor) .SingleOrDefaultAsync(cancellationToken: token); + if (entity == default) + { + _logger.LogWarning("Honor with ID {HonorId} not found for pathfinder with ID {PathfinderId}", honorId, pathfinderId); + } + else + { + _logger.LogInformation("Retrieved honor with ID {HonorId} for pathfinder with ID {PathfinderId}", honorId, pathfinderId); + } + return entity == default ? default : _mapper.Map(entity); @@ -86,67 +100,85 @@ public PathfinderHonorService( public async Task AddAsync(Guid pathfinderId, Incoming.PostPathfinderHonorDto incomingPathfinderHonor, CancellationToken token) { + _logger.LogInformation("Adding honor for pathfinder with ID {PathfinderId}", pathfinderId); + try + { + Incoming.PathfinderHonorDto newPathfinderHonor = await MapStatus(pathfinderId, incomingPathfinderHonor, token); - Incoming.PathfinderHonorDto newPathfinderHonor = await MapStatus(pathfinderId, incomingPathfinderHonor, token); - - - await _validator.ValidateAsync( - newPathfinderHonor, - opts => opts.ThrowOnFailures() + await _validator.ValidateAsync( + newPathfinderHonor, + opts => opts.ThrowOnFailures() .IncludeRulesNotInRuleSet() .IncludeRuleSets("post"), - token); + token); - var newEntity = _mapper.Map(newPathfinderHonor); + var newEntity = _mapper.Map(newPathfinderHonor); - if (newPathfinderHonor.Status == HonorStatus.Earned.ToString()) - { - newEntity.Earned = DateTime.UtcNow; - } + if (newPathfinderHonor.Status == HonorStatus.Earned.ToString()) + { + newEntity.Earned = DateTime.UtcNow; + } - await _dbContext.AddAsync(newEntity, token); - await _dbContext.SaveChangesAsync(token); - _logger.LogInformation($"Pathfinder honor(Id: {newEntity.PathfinderHonorID} added to database."); + await _dbContext.AddAsync(newEntity, token); + await _dbContext.SaveChangesAsync(token); + _logger.LogInformation("Added honor with ID {HonorId} for pathfinder with ID {PathfinderId}", newEntity.HonorID, pathfinderId); - var createdEntity = await GetFilteredPathfinderHonors(newEntity.PathfinderID, newEntity.HonorID, token) - .Include(phs => phs.PathfinderHonorStatus) - .Include(h => h.Honor) - .SingleOrDefaultAsync(cancellationToken: token); + var createdEntity = await GetFilteredPathfinderHonors(newEntity.PathfinderID, newEntity.HonorID, token) + .Include(phs => phs.PathfinderHonorStatus) + .Include(h => h.Honor) + .SingleOrDefaultAsync(cancellationToken: token); - return _mapper.Map(createdEntity); + return _mapper.Map(createdEntity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding honor for pathfinder with ID {PathfinderId}", pathfinderId); + throw; + } } public async Task UpdateAsync(Guid pathfinderId, Guid honorId, Incoming.PutPathfinderHonorDto incomingPathfinderHonor, CancellationToken token) { - var targetPathfinderHonor = await _dbContext.PathfinderHonors + _logger.LogInformation("Updating honor with ID {HonorId} for pathfinder with ID {PathfinderId}", honorId, pathfinderId); + try + { + var targetPathfinderHonor = await _dbContext.PathfinderHonors .Where(p => p.PathfinderID == pathfinderId && p.HonorID == honorId) .Include(phs => phs.PathfinderHonorStatus) .Include(h => h.Honor) .SingleOrDefaultAsync(token); - if (targetPathfinderHonor == default) - { - return default; - } + if (targetPathfinderHonor == default) + { + _logger.LogWarning("Honor with ID {HonorId} not found for pathfinder with ID {PathfinderId}", honorId, pathfinderId); + return default; + } - Incoming.PathfinderHonorDto updatedPathfinderHonor = await MapStatus(pathfinderId, incomingPathfinderHonor, token, honorId); + Incoming.PathfinderHonorDto updatedPathfinderHonor = await MapStatus(pathfinderId, incomingPathfinderHonor, token, honorId); - await _validator.ValidateAsync( - updatedPathfinderHonor, - opts => opts.ThrowOnFailures() + await _validator.ValidateAsync( + updatedPathfinderHonor, + opts => opts.ThrowOnFailures() .IncludeRulesNotInRuleSet(), - token); + token); - targetPathfinderHonor.StatusCode = updatedPathfinderHonor.StatusCode; + targetPathfinderHonor.StatusCode = updatedPathfinderHonor.StatusCode; - if (updatedPathfinderHonor.Status == HonorStatus.Earned.ToString()) - { - targetPathfinderHonor.Earned = DateTime.UtcNow; - } + if (updatedPathfinderHonor.Status == HonorStatus.Earned.ToString()) + { + targetPathfinderHonor.Earned = DateTime.UtcNow; + } - await _dbContext.SaveChangesAsync(token); + await _dbContext.SaveChangesAsync(token); + _logger.LogInformation("Updated honor with ID {HonorId} for pathfinder with ID {PathfinderId}", honorId, pathfinderId); - return _mapper.Map(targetPathfinderHonor); + return _mapper.Map(targetPathfinderHonor); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating honor with ID {HonorId} for pathfinder with ID {PathfinderId}", honorId, pathfinderId); + throw; + } } private IQueryable GetFilteredPathfinderHonors(Guid pathfinderId, Guid honorId, CancellationToken token) @@ -159,8 +191,8 @@ private IQueryable GetFilteredPathfinderHonors(Guid pathfinderI private async Task MapStatus(Guid pathfinderId, dynamic upsertPathfinderHonor, CancellationToken token, Guid honorId = new Guid()) { - - + _logger.LogInformation("Mapping status for honor with ID {HonorId} for pathfinder with ID {PathfinderId}", honorId, pathfinderId); + Enum.TryParse(upsertPathfinderHonor.Status, true, out HonorStatus statusEntity); try @@ -169,8 +201,9 @@ private IQueryable GetFilteredPathfinderHonors(Guid pathfinderI .Where(s => s.Status == statusEntity.ToString()) .SingleAsync(token); } - catch (Exception) + catch (Exception ex) { + _logger.LogWarning(ex, "Status {Status} not found, using Unknown status", statusEntity.ToString()); _honorStatus = new() { Status = "Unknown", @@ -178,13 +211,11 @@ private IQueryable GetFilteredPathfinderHonors(Guid pathfinderI }; } - if (honorId == Guid.Empty) { honorId = upsertPathfinderHonor.HonorID; } - Incoming.PathfinderHonorDto mappedPathfinderHonor = new() { HonorID = honorId, diff --git a/PathfinderHonorManager/Service/PathfinderService.cs b/PathfinderHonorManager/Service/PathfinderService.cs index d9bc446a..4ae83c20 100644 --- a/PathfinderHonorManager/Service/PathfinderService.cs +++ b/PathfinderHonorManager/Service/PathfinderService.cs @@ -24,7 +24,7 @@ public class PathfinderService : IPathfinderService private readonly IMapper _mapper; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IValidator _validator; @@ -66,27 +66,45 @@ public PathfinderService( public async Task> GetAllAsync(string clubCode, bool showInactive, CancellationToken token) { + _logger.LogInformation("Getting all pathfinders for club {ClubCode}, showInactive: {ShowInactive}", clubCode, showInactive); + ICollection pathfinderDtos = await QueryPathfindersWithAchievementCountsAsync(clubCode, showInactive) .OrderBy(p => p.Grade) .ThenBy(p => p.LastName) .ToListAsync(token); + _logger.LogInformation("Retrieved {Count} pathfinders for club {ClubCode}", pathfinderDtos.Count, clubCode); return pathfinderDtos; } public async Task GetByIdAsync(Guid id, string clubCode, CancellationToken token) { - Outgoing.PathfinderDependantDto dto; - - dto = await QueryPathfindersWithAchievementCountsAsync(clubCode, true) + _logger.LogInformation("Getting pathfinder with ID {PathfinderId} for club {ClubCode}", id, clubCode); + + Outgoing.PathfinderDependantDto dto = await QueryPathfindersWithAchievementCountsAsync(clubCode, true) .SingleOrDefaultAsync(p => p.PathfinderID == id, token); + if (dto == default) + { + _logger.LogWarning("Pathfinder with ID {PathfinderId} not found for club {ClubCode}", id, clubCode); + } + else + { + _logger.LogInformation("Retrieved pathfinder with ID {PathfinderId} for club {ClubCode}", id, clubCode); + } + return dto; } public async Task AddAsync(Incoming.PathfinderDto newPathfinder, string clubCode, CancellationToken token) { + _logger.LogInformation("Adding new pathfinder for club {ClubCode}", clubCode); + var club = await _clubService.GetByCodeAsync(clubCode, token); + if (club == null) + { + _logger.LogWarning("Club with code {ClubCode} not found while adding pathfinder", clubCode); + } var newPathfinderWithClubId = new Incoming.PathfinderDtoInternal() { @@ -97,69 +115,91 @@ public PathfinderService( ClubID = club?.ClubID ?? Guid.Empty }; - await _validator.ValidateAsync( - newPathfinderWithClubId, - opts => opts.ThrowOnFailures() - .IncludeAllRuleSets(), - token); + try + { + await _validator.ValidateAsync( + newPathfinderWithClubId, + opts => opts.ThrowOnFailures() + .IncludeAllRuleSets(), + token); - var newEntity = _mapper.Map(newPathfinderWithClubId); + var newEntity = _mapper.Map(newPathfinderWithClubId); - await _dbContext.AddAsync(newEntity, token); - await _dbContext.SaveChangesAsync(token); - _logger.LogInformation($"Pathfinder(Id: {newEntity.PathfinderID} added to database."); + await _dbContext.AddAsync(newEntity, token); + await _dbContext.SaveChangesAsync(token); + _logger.LogInformation("Added pathfinder with ID {PathfinderId} to database for club {ClubCode}", newEntity.PathfinderID, clubCode); - var createdPathfinder = await GetByIdAsync(newEntity.PathfinderID, clubCode, token); + var createdPathfinder = await GetByIdAsync(newEntity.PathfinderID, clubCode, token); - return _mapper.Map(createdPathfinder); + return _mapper.Map(createdPathfinder); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding pathfinder for club {ClubCode}", clubCode); + throw; + } } public async Task UpdateAsync(Guid pathfinderId, Incoming.PutPathfinderDto updatedPathfinder, string clubCode, CancellationToken token) { - Pathfinder targetPathfinder; - targetPathfinder = await QueryPathfinderByIdAsync(pathfinderId, clubCode) + _logger.LogInformation("Updating pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinderId, clubCode); + + Pathfinder targetPathfinder = await QueryPathfinderByIdAsync(pathfinderId, clubCode) .SingleOrDefaultAsync(token); var club = await _clubService.GetByCodeAsync(clubCode, token); + if (club == null) + { + _logger.LogWarning("Club with code {ClubCode} not found while updating pathfinder {PathfinderId}", clubCode, pathfinderId); + } if (targetPathfinder == default) { + _logger.LogWarning("Pathfinder with ID {PathfinderId} not found for club {ClubCode}", pathfinderId, clubCode); return default; } - Incoming.PathfinderDtoInternal mappedPathfinder = new() + try { - FirstName = targetPathfinder.FirstName, - LastName = targetPathfinder.LastName, - Email = targetPathfinder.Email, - Grade = updatedPathfinder.Grade, - IsActive = updatedPathfinder.IsActive, - ClubID = club.ClubID - }; + Incoming.PathfinderDtoInternal mappedPathfinder = new() + { + FirstName = targetPathfinder.FirstName, + LastName = targetPathfinder.LastName, + Email = targetPathfinder.Email, + Grade = updatedPathfinder.Grade, + IsActive = updatedPathfinder.IsActive, + ClubID = club.ClubID + }; + + await _validator.ValidateAsync( + mappedPathfinder, + opts => opts.ThrowOnFailures(), + token); + + if (mappedPathfinder.Grade != null) + { + targetPathfinder.Grade = mappedPathfinder.Grade; + } + else + { + targetPathfinder.Grade = null; + } + + if (mappedPathfinder.IsActive.HasValue) + { + targetPathfinder.IsActive = mappedPathfinder.IsActive; + } - await _validator.ValidateAsync( - mappedPathfinder, - opts => opts.ThrowOnFailures(), - token); + await _dbContext.SaveChangesAsync(token); + _logger.LogInformation("Updated pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinderId, clubCode); - if (mappedPathfinder.Grade != null) - { - targetPathfinder.Grade = mappedPathfinder.Grade; - } - else - { - targetPathfinder.Grade = null; + return _mapper.Map(targetPathfinder); } - - if (mappedPathfinder.IsActive.HasValue) + catch (Exception ex) { - targetPathfinder.IsActive = mappedPathfinder.IsActive; + _logger.LogError(ex, "Error updating pathfinder with ID {PathfinderId} for club {ClubCode}", pathfinderId, clubCode); + throw; } - - await _dbContext.SaveChangesAsync(token); - - return _mapper.Map(targetPathfinder); - } public async Task> BulkUpdateAsync( @@ -167,36 +207,52 @@ await _validator.ValidateAsync( string clubCode, CancellationToken token) { + _logger.LogInformation("Bulk updating {Count} pathfinders for club {ClubCode}", bulkData.Count(), clubCode); + var updatedPathfinders = new List(); foreach (var data in bulkData) { foreach (var item in data.Items) { - var targetPathfinder = await QueryPathfinderByIdAsync(item.PathfinderId, clubCode) - .SingleOrDefaultAsync(token); - - if (targetPathfinder != null) + try { - if (item.Grade.HasValue) - { - targetPathfinder.Grade = item.Grade.Value; - } + var targetPathfinder = await QueryPathfinderByIdAsync(item.PathfinderId, clubCode) + .SingleOrDefaultAsync(token); - if (item.IsActive.HasValue) + if (targetPathfinder != null) { - targetPathfinder.IsActive = item.IsActive.Value; - } + if (item.Grade.HasValue) + { + targetPathfinder.Grade = item.Grade.Value; + } - var mappedPathfinder = _mapper.Map(targetPathfinder); - await _validator.ValidateAsync(mappedPathfinder, opts => opts.ThrowOnFailures(), token); + if (item.IsActive.HasValue) + { + targetPathfinder.IsActive = item.IsActive.Value; + } - updatedPathfinders.Add(_mapper.Map(targetPathfinder)); + var mappedPathfinder = _mapper.Map(targetPathfinder); + await _validator.ValidateAsync(mappedPathfinder, opts => opts.ThrowOnFailures(), token); + + updatedPathfinders.Add(_mapper.Map(targetPathfinder)); + _logger.LogInformation("Updated pathfinder with ID {PathfinderId} during bulk update for club {ClubCode}", item.PathfinderId, clubCode); + } + else + { + _logger.LogWarning("Pathfinder with ID {PathfinderId} not found during bulk update for club {ClubCode}", item.PathfinderId, clubCode); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating pathfinder with ID {PathfinderId} during bulk update for club {ClubCode}", item.PathfinderId, clubCode); + throw; } } } await _dbContext.SaveChangesAsync(token); + _logger.LogInformation("Completed bulk update of {Count} pathfinders for club {ClubCode}", bulkData.Count(), clubCode); return updatedPathfinders; } diff --git a/PathfinderHonorManager/Startup.cs b/PathfinderHonorManager/Startup.cs index 1a98bf07..53bfa687 100644 --- a/PathfinderHonorManager/Startup.cs +++ b/PathfinderHonorManager/Startup.cs @@ -29,12 +29,14 @@ namespace PathfinderHonorManager { public class Startup { - public Startup(IConfiguration configuration) + public Startup(IConfiguration configuration, IWebHostEnvironment environment) { Configuration = configuration; + Environment = environment; } public IConfiguration Configuration { get; } + public IWebHostEnvironment Environment { get; } public void ConfigureServices(IServiceCollection services) { @@ -51,7 +53,12 @@ public void ConfigureServices(IServiceCollection services) NameClaimType = ClaimTypes.NameIdentifier }; }); - services.AddApplicationInsightsTelemetry(); + + if (!Environment.IsDevelopment()) + { + services.AddApplicationInsightsTelemetry(); + } + services.AddAuthorization(options => { options.AddPolicy("ReadPathfinders", policy => policy.Requirements.Add(new HasScopeRequirement("read:pathfinders", domain))); diff --git a/PathfinderHonorManager/Validators/ClubValidator.cs b/PathfinderHonorManager/Validators/ClubValidator.cs new file mode 100644 index 00000000..466a909e --- /dev/null +++ b/PathfinderHonorManager/Validators/ClubValidator.cs @@ -0,0 +1,43 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using PathfinderHonorManager.DataAccess; +using PathfinderHonorManager.Dto.Incoming; +using System.Text.RegularExpressions; + +namespace PathfinderHonorManager.Validators +{ + public class ClubValidator : AbstractValidator + { + private readonly PathfinderContext _dbContext; + + public ClubValidator(PathfinderContext dbContext) + { + _dbContext = dbContext; + SetUpValidation(); + } + + private void SetUpValidation() + { + RuleFor(c => c.Name) + .NotEmpty() + .MaximumLength(100) + .WithMessage("Club name must not be empty and must not exceed 100 characters."); + + RuleFor(c => c.ClubCode) + .NotEmpty() + .Length(4, 20) + .Matches(new Regex("^[A-Z0-9]+$")) + .WithMessage("Club code must be between 4 and 20 characters and contain only uppercase letters and numbers."); + + RuleSet( + "post", + () => + { + RuleFor(c => c.ClubCode) + .MustAsync(async (code, token) => + !await _dbContext.Clubs.AnyAsync(c => c.ClubCode == code, token)) + .WithMessage(c => $"Club code {c.ClubCode} is already in use."); + }); + } + } +} \ No newline at end of file diff --git a/PathfinderHonorManager/appsettings.Development.json b/PathfinderHonorManager/appsettings.Development.json index d9215cbb..dd5095b8 100644 --- a/PathfinderHonorManager/appsettings.Development.json +++ b/PathfinderHonorManager/appsettings.Development.json @@ -6,9 +6,22 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore.Routing": "Trace" + "Microsoft": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Information", + "PathfinderHonorManager": "Debug" }, - "AllowedHosts": "*" + "Console": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Information", + "PathfinderHonorManager": "Debug" + } + } }, "AllowedHosts": "*", "Auth0": {