diff --git a/src/Auth/AuthAPI/Program.cs b/src/Auth/AuthAPI/Program.cs index f46afe5..a99009a 100644 --- a/src/Auth/AuthAPI/Program.cs +++ b/src/Auth/AuthAPI/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using AuthAPI; var builder = WebApplication.CreateBuilder(args); +builder.Logging.SetMinimumLevel(LogLevel.Error); builder.Services.AddHttpClient(); builder.Services.Configure( builder.Configuration.GetSection("EnvironmentVariables")); @@ -18,7 +19,8 @@ app.MapPost("/validate", async ( [FromBody] Request validationRequest, HttpClient httpClient, MemberValidationService memberService, - IOptions settings) => + IOptions settings, + ILogger logger) => { var request = memberService.GetRequestWithApiKey(validationRequest); if (request.IsFailure) @@ -29,10 +31,12 @@ app.MapPost("/validate", async ( request.Success); if (!response.IsSuccessStatusCode) + { + logger.LogError("Sverok validation returned non-success status code: {StatusCode}", (int)response.StatusCode); return Results.StatusCode((int)response.StatusCode); + } var content = await response.Content.ReadAsStringAsync(); - Console.WriteLine($"[DEBUG] Sverok response: {content}"); return content.Contains("\"member_found\":true") ? Results.Ok() : Results.NotFound(); }) .WithName("ValidateMember"); diff --git a/src/Registration/Registration.API/Configuration/VbytesRelayOptions.cs b/src/Registration/Registration.API/Configuration/VbytesRelayOptions.cs index 28e457f..348e067 100644 --- a/src/Registration/Registration.API/Configuration/VbytesRelayOptions.cs +++ b/src/Registration/Registration.API/Configuration/VbytesRelayOptions.cs @@ -7,5 +7,5 @@ public class VbytesRelayOptions public string ApiKeyHeaderName { get; set; } = "X-Api-Key"; public string ApiKey { get; set; } = string.Empty; public string ClientCertificatePfxPath { get; set; } = string.Empty; - public string VolunteerRegisterPath { get; set; } = "/api/volunteer/register"; + public string VolunteerRegisterPath { get; set; } = string.Empty; } diff --git a/src/Registration/Registration.API/Controllers/ParticipantController.cs b/src/Registration/Registration.API/Controllers/ParticipantController.cs index a38f8fb..5aaf78c 100644 --- a/src/Registration/Registration.API/Controllers/ParticipantController.cs +++ b/src/Registration/Registration.API/Controllers/ParticipantController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using Registration.API.RequestModels; using Registration.API.Services; +using Registration.API.Validation; using Registration.Domain.Models; namespace Registration.API.Controllers; @@ -11,8 +13,33 @@ public class ParticipantController(IVbytesParticipantRelayService relayService) private readonly IVbytesParticipantRelayService _relayService = relayService; [HttpPost("register")] - public async Task RegisterForLan([FromBody] Participant participant, CancellationToken cancellationToken) + public async Task RegisterForLan([FromBody] ParticipantRegistrationRequest request, CancellationToken cancellationToken) { + var normalizedGuardianPhone = InputNormalization.NormalizeSwedishMobile(request.GuardianPhoneNumber); + + string? normalizedParticipantPhone = null; + if (!string.IsNullOrWhiteSpace(request.PhoneNumber)) + { + normalizedParticipantPhone = InputNormalization.NormalizeSwedishMobile(request.PhoneNumber); + } + + var participant = new Participant + { + IsMember = request.IsMember!.Value, + FirstName = request.FirstName.Trim(), + SurName = request.SurName.Trim(), + Grade = request.Grade.Trim(), + PhoneNumber = normalizedParticipantPhone, + Email = request.Email?.Trim(), + GuardianName = request.GuardianName.Trim(), + GuardianPhoneNumber = normalizedGuardianPhone, + GuardianEmail = request.GuardianEmail.Trim(), + IsVisitor = request.IsVisitor!.Value, + HasApprovedGdpr = request.HasApprovedGdpr!.Value, + Friends = request.Friends?.Trim(), + SpecialDiet = request.SpecialDiet?.Trim(), + }; + var result = await _relayService.RegisterParticipantAsync(participant, cancellationToken); if (result.StatusCode == 200 && !result.Message.Contains("401")) diff --git a/src/Registration/Registration.API/Controllers/RegistrationController.cs b/src/Registration/Registration.API/Controllers/RegistrationController.cs index 8ef425f..7d6a9c7 100644 --- a/src/Registration/Registration.API/Controllers/RegistrationController.cs +++ b/src/Registration/Registration.API/Controllers/RegistrationController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Registration.API.Services; +using Registration.API.Validation; using Registration.Infra.Repositories; namespace Registration.API.Controllers @@ -14,14 +15,24 @@ namespace Registration.API.Controllers [HttpGet("register/{ssn}")] public async Task ValidateSsn(string ssn, CancellationToken cancellationToken) { - var isMember = await _authService.IsMemberAsync(ssn, cancellationToken); + if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult)) + { + return errorResult; + } + + var isMember = await _authService.IsMemberAsync(normalizedSsn, cancellationToken); return isMember ? Ok() : NotFound(); } [HttpPost("register/{ssn}")] public async Task RegisterMember(string ssn) { - var added = await _memberRepository.AddRegistration(ssn); + if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult)) + { + return errorResult; + } + + var added = await _memberRepository.AddRegistration(normalizedSsn); return added ? Ok() : Conflict(); } @@ -29,7 +40,12 @@ namespace Registration.API.Controllers [HttpGet("registered/{ssn}")] public async Task IsMemberRegistered(string ssn) { - var isRegistered = await _memberRepository.GetIsRegistered(ssn); + if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult)) + { + return errorResult; + } + + var isRegistered = await _memberRepository.GetIsRegistered(normalizedSsn); return isRegistered ? Conflict() : Ok(); } @@ -39,5 +55,25 @@ namespace Registration.API.Controllers await _memberRepository.ClearRegistrations(); return Ok(new { Message = "All registrations cleared." }); } + + private bool TryNormalizeSsn(string ssn, out string normalizedSsn, out IActionResult errorResult) + { + normalizedSsn = InputNormalization.NormalizeSsn(ssn); + if (InputNormalization.IsValidSsn(normalizedSsn)) + { + errorResult = Ok(); + return true; + } + + errorResult = BadRequest(new ValidationProblemDetails(new Dictionary + { + ["ssn"] = ["SSN must contain 10 or 12 digits."] + }) + { + Title = "One or more validation errors occurred.", + Status = StatusCodes.Status400BadRequest + }); + return false; + } } } diff --git a/src/Registration/Registration.API/Controllers/VolunteerController.cs b/src/Registration/Registration.API/Controllers/VolunteerController.cs index 2992c3e..727d0ac 100644 --- a/src/Registration/Registration.API/Controllers/VolunteerController.cs +++ b/src/Registration/Registration.API/Controllers/VolunteerController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using Registration.API.RequestModels; using Registration.API.Services; +using Registration.API.Validation; using Registration.Domain.Models; namespace Registration.API.Controllers; @@ -11,8 +13,35 @@ public class VolunteerController(IVbytesVolunteerRelayService relayService) : Co private readonly IVbytesVolunteerRelayService _relayService = relayService; [HttpPost("register")] - public async Task RegisterVolunteer([FromBody] Volunteer volunteer, CancellationToken cancellationToken) + public async Task RegisterVolunteer([FromBody] VolunteerRegistrationRequest request, CancellationToken cancellationToken) { + var normalizedPhone = InputNormalization.NormalizeSwedishMobile(request.PhoneNumber); + + var trimmedAreas = request.AreasOfInterest + .Select(area => area.Name.Trim()) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToList(); + + if (trimmedAreas.Count == 0) + { + ModelState.AddModelError(nameof(request.AreasOfInterest), "At least one valid area of interest is required."); + } + + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + var volunteer = new Volunteer + { + FirstName = request.FirstName.Trim(), + SurName = request.SurName.Trim(), + PhoneNumber = normalizedPhone, + Email = request.Email.Trim(), + HasApprovedGdpr = request.HasApprovedGdpr!.Value, + AreasOfInterest = trimmedAreas.Select(name => new AreasOfInterest { Name = name }).ToList() + }; + var result = await _relayService.RegisterVolunteerAsync(volunteer, cancellationToken); if (result.StatusCode == 200 && !result.Message.Contains("401")) diff --git a/src/Registration/Registration.API/Program.cs b/src/Registration/Registration.API/Program.cs index e6c479f..f1c2992 100644 --- a/src/Registration/Registration.API/Program.cs +++ b/src/Registration/Registration.API/Program.cs @@ -4,6 +4,7 @@ using Registration.API.Configuration; using Registration.API.Services; var builder = WebApplication.CreateBuilder(args); +builder.Logging.SetMinimumLevel(LogLevel.Error); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/src/Registration/Registration.API/RequestModels/AreaOfInterestRequest.cs b/src/Registration/Registration.API/RequestModels/AreaOfInterestRequest.cs new file mode 100644 index 0000000..40b0b57 --- /dev/null +++ b/src/Registration/Registration.API/RequestModels/AreaOfInterestRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Registration.API.RequestModels; + +public class AreaOfInterestRequest +{ + [Required] + public string Name { get; set; } = string.Empty; +} diff --git a/src/Registration/Registration.API/RequestModels/ParticipantRegistrationRequest.cs b/src/Registration/Registration.API/RequestModels/ParticipantRegistrationRequest.cs new file mode 100644 index 0000000..627ee35 --- /dev/null +++ b/src/Registration/Registration.API/RequestModels/ParticipantRegistrationRequest.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace Registration.API.RequestModels; + +public class ParticipantRegistrationRequest +{ + [Required] + public bool? IsMember { get; set; } + + [Required] + public string FirstName { get; set; } = string.Empty; + + [Required] + public string SurName { get; set; } = string.Empty; + + [Required] + public string Grade { get; set; } = string.Empty; + + public string? PhoneNumber { get; set; } + + [EmailAddress] + public string? Email { get; set; } + + [Required] + public string GuardianName { get; set; } = string.Empty; + + [Required] + public string GuardianPhoneNumber { get; set; } = string.Empty; + + [Required] + [EmailAddress] + public string GuardianEmail { get; set; } = string.Empty; + + [Required] + public bool? IsVisitor { get; set; } + + [Required] + public bool? HasApprovedGdpr { get; set; } + + public string? Friends { get; set; } + + public string? SpecialDiet { get; set; } +} diff --git a/src/Registration/Registration.API/RequestModels/VolunteerRegistrationRequest.cs b/src/Registration/Registration.API/RequestModels/VolunteerRegistrationRequest.cs new file mode 100644 index 0000000..f0bbd25 --- /dev/null +++ b/src/Registration/Registration.API/RequestModels/VolunteerRegistrationRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Registration.API.RequestModels; + +public class VolunteerRegistrationRequest +{ + [Required] + public string FirstName { get; set; } = string.Empty; + + [Required] + public string SurName { get; set; } = string.Empty; + + [Required] + public string PhoneNumber { get; set; } = string.Empty; + + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + public bool? HasApprovedGdpr { get; set; } + + [Required] + [MinLength(1)] + public List AreasOfInterest { get; set; } = []; +} diff --git a/src/Registration/Registration.API/Services/VbytesParticipantRelayService.cs b/src/Registration/Registration.API/Services/VbytesParticipantRelayService.cs index ec8437c..de6a269 100644 --- a/src/Registration/Registration.API/Services/VbytesParticipantRelayService.cs +++ b/src/Registration/Registration.API/Services/VbytesParticipantRelayService.cs @@ -9,7 +9,7 @@ public class VbytesParticipantRelayService( HttpClient httpClient, IOptions options, ILogger logger) - : VbytesRelayServiceBase(httpClient, options), IVbytesParticipantRelayService + : VbytesRelayServiceBase(httpClient, options, logger), IVbytesParticipantRelayService { public const string HttpClientName = "VbytesRelay"; @@ -20,24 +20,22 @@ public class VbytesParticipantRelayService( var configError = ValidateConfiguration(); if (configError is not null) { - _logger.LogWarning("Relay configuration invalid: {Message}", configError.Message); + _logger.LogError("Relay configuration invalid: {Message}", configError.Message); return configError; } var payload = Map(participant); - _logger.LogInformation("Relaying participant to {Path}. Payload: {@Payload}", Options.ParticipantRegisterPath, payload); try { var result = await SendAsync(Options.ParticipantRegisterPath, payload, cancellationToken); - _logger.LogInformation("Relay response: {StatusCode} - {Message}", result.StatusCode, result.Message); return result; } catch (TaskCanceledException) { - _logger.LogWarning("Relay timed out calling {Path}", Options.ParticipantRegisterPath); + _logger.LogError("Relay timed out calling {Path}", Options.ParticipantRegisterPath); return new VbytesRelayResult(false, StatusCodes.Status504GatewayTimeout, "Upstream timeout."); } diff --git a/src/Registration/Registration.API/Services/VbytesRelayServiceBase.cs b/src/Registration/Registration.API/Services/VbytesRelayServiceBase.cs index 999cd75..85188d8 100644 --- a/src/Registration/Registration.API/Services/VbytesRelayServiceBase.cs +++ b/src/Registration/Registration.API/Services/VbytesRelayServiceBase.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Microsoft.Extensions.Options; using Registration.API.Configuration; @@ -6,9 +5,11 @@ namespace Registration.API.Services; public abstract class VbytesRelayServiceBase( HttpClient httpClient, - IOptions options) + IOptions options, + ILogger logger) { private readonly HttpClient _httpClient = httpClient; + private readonly ILogger _logger = logger; protected readonly VbytesRelayOptions Options = options.Value; protected async Task SendAsync(string path, object payload, CancellationToken cancellationToken) @@ -22,16 +23,16 @@ public abstract class VbytesRelayServiceBase( using var response = await _httpClient.SendAsync(request, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); + var safeBody = body.Length > 600 ? body[..600] : body; if (response.IsSuccessStatusCode) { - Console.WriteLine($"[DEBUG] Relay success body: {body}"); return new VbytesRelayResult(true, StatusCodes.Status200OK, body); } var statusCode = (int)response.StatusCode; - Console.WriteLine($"[DEBUG] Relay error body: {body}"); - var message = body.Length > 600 ? body[..600] : body; + _logger.LogError("Outbound relay response error: {StatusCode}. Body: {Body}", statusCode, safeBody); + var message = safeBody; return new VbytesRelayResult(false, statusCode, string.IsNullOrWhiteSpace(message) ? "Upstream request failed." : message); } @@ -60,17 +61,6 @@ public abstract class VbytesRelayServiceBase( return path.StartsWith('/') ? path : $"/{path}"; } - private static async Task ReadSafeErrorMessage(HttpResponseMessage response, CancellationToken cancellationToken) - { - var body = await response.Content.ReadAsStringAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(body)) - { - return "Upstream request failed."; - } - - return body.Length > 600 ? body[..600] : body; - } - protected static bool IsConfigured(string value) { return !string.IsNullOrWhiteSpace(value) && !value.StartsWith("__SET_", StringComparison.Ordinal); diff --git a/src/Registration/Registration.API/Services/VbytesVolunteerRelayService.cs b/src/Registration/Registration.API/Services/VbytesVolunteerRelayService.cs index 52f11a1..8a0017b 100644 --- a/src/Registration/Registration.API/Services/VbytesVolunteerRelayService.cs +++ b/src/Registration/Registration.API/Services/VbytesVolunteerRelayService.cs @@ -9,7 +9,7 @@ public class VbytesVolunteerRelayService( HttpClient httpClient, IOptions options, ILogger logger) - : VbytesRelayServiceBase(httpClient, options), IVbytesVolunteerRelayService + : VbytesRelayServiceBase(httpClient, options, logger), IVbytesVolunteerRelayService { private readonly ILogger _logger = logger; diff --git a/src/Registration/Registration.API/Validation/InputNormalization.cs b/src/Registration/Registration.API/Validation/InputNormalization.cs new file mode 100644 index 0000000..32a11b6 --- /dev/null +++ b/src/Registration/Registration.API/Validation/InputNormalization.cs @@ -0,0 +1,31 @@ +using System.Text.RegularExpressions; + +namespace Registration.API.Validation; + +public static partial class InputNormalization +{ + public static string NormalizeSsn(string value) + { + var raw = value ?? string.Empty; + return NonDigitsRegex().Replace(raw, string.Empty); + } + + public static bool IsValidSsn(string value) + { + return value.Length is 10 or 12; + } + + public static string NormalizeSwedishMobile(string value) + { + var digitsOnly = NonDigitsRegex().Replace(value ?? string.Empty, string.Empty); + + if (digitsOnly.StartsWith("0046")) return $"0{digitsOnly[4..]}"; + if (digitsOnly.StartsWith("46")) return $"0{digitsOnly[2..]}"; + if (digitsOnly.StartsWith('7')) return $"0{digitsOnly}"; + + return digitsOnly; + } + + [GeneratedRegex("\\D")] + private static partial Regex NonDigitsRegex(); +}