Cleanrer validation, List in frontend for classes

This commit is contained in:
Kruille 2026-02-24 17:53:16 +01:00
parent 378f7fbf2a
commit aff4e3d557
12 changed files with 95 additions and 63 deletions

View file

@ -15,13 +15,8 @@ public class ParticipantController(IVbytesParticipantRelayService relayService)
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> RegisterForLan([FromBody] ParticipantRegistrationRequest request, CancellationToken cancellationToken) public async Task<IActionResult> RegisterForLan([FromBody] ParticipantRegistrationRequest request, CancellationToken cancellationToken)
{ {
var normalizedGuardianPhone = InputNormalization.NormalizeSwedishMobile(request.GuardianPhoneNumber); var normalizedGuardianPhone = request.GuardianPhoneNumber.NormalizePhone();
var normalizedParticipantPhone = request.PhoneNumber.NormalizeOptionalPhone();
string? normalizedParticipantPhone = null;
if (!string.IsNullOrWhiteSpace(request.PhoneNumber))
{
normalizedParticipantPhone = InputNormalization.NormalizeSwedishMobile(request.PhoneNumber);
}
var participant = new Participant var participant = new Participant
{ {
@ -42,7 +37,7 @@ public class ParticipantController(IVbytesParticipantRelayService relayService)
var result = await _relayService.RegisterParticipantAsync(participant, cancellationToken); var result = await _relayService.RegisterParticipantAsync(participant, cancellationToken);
if (result.StatusCode == 200 && !result.Message.Contains("401")) if (result.Success)
{ {
return Ok(); return Ok();
} }

View file

@ -15,9 +15,9 @@ namespace Registration.API.Controllers
[HttpGet("register/{ssn}")] [HttpGet("register/{ssn}")]
public async Task<IActionResult> ValidateSsn(string ssn, CancellationToken cancellationToken) public async Task<IActionResult> ValidateSsn(string ssn, CancellationToken cancellationToken)
{ {
if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult)) if (!ssn.TryNormalizeSwedishSsn(out var normalizedSsn, out var errorResult))
{ {
return errorResult; return errorResult!;
} }
var isMember = await _authService.IsMemberAsync(normalizedSsn, cancellationToken); var isMember = await _authService.IsMemberAsync(normalizedSsn, cancellationToken);
@ -25,11 +25,11 @@ namespace Registration.API.Controllers
} }
[HttpPost("register/{ssn}")] [HttpPost("register/{ssn}")]
public async Task<IActionResult> RegisterMember(string ssn) public async Task<IActionResult> RegisterMember(string ssn, CancellationToken cancellationToken)
{ {
if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult)) if (!ssn.TryNormalizeSwedishSsn(out var normalizedSsn, out var errorResult))
{ {
return errorResult; return errorResult!;
} }
var added = await _memberRepository.AddRegistration(normalizedSsn); var added = await _memberRepository.AddRegistration(normalizedSsn);
@ -40,9 +40,9 @@ namespace Registration.API.Controllers
[HttpGet("registered/{ssn}")] [HttpGet("registered/{ssn}")]
public async Task<IActionResult> IsMemberRegistered(string ssn) public async Task<IActionResult> IsMemberRegistered(string ssn)
{ {
if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult)) if (!ssn.TryNormalizeSwedishSsn(out var normalizedSsn, out var errorResult))
{ {
return errorResult; return errorResult!;
} }
var isRegistered = await _memberRepository.GetIsRegistered(normalizedSsn); var isRegistered = await _memberRepository.GetIsRegistered(normalizedSsn);
@ -50,30 +50,12 @@ namespace Registration.API.Controllers
} }
[HttpDelete("clear")] [HttpDelete("clear")]
public async Task<IActionResult> ClearRegistrations() public async Task<IActionResult> ClearRegistrations(CancellationToken cancellationToken)
{ {
await _memberRepository.ClearRegistrations(); await _memberRepository.ClearRegistrations();
return Ok(new { Message = "All registrations cleared." }); 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<string, string[]>
{
["ssn"] = ["SSN must contain 10 or 12 digits."]
})
{
Title = "One or more validation errors occurred.",
Status = StatusCodes.Status400BadRequest
});
return false;
}
} }
} }

View file

@ -15,7 +15,7 @@ public class VolunteerController(IVbytesVolunteerRelayService relayService) : Co
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> RegisterVolunteer([FromBody] VolunteerRegistrationRequest request, CancellationToken cancellationToken) public async Task<IActionResult> RegisterVolunteer([FromBody] VolunteerRegistrationRequest request, CancellationToken cancellationToken)
{ {
var normalizedPhone = InputNormalization.NormalizeSwedishMobile(request.PhoneNumber); var normalizedPhone = request.PhoneNumber.NormalizePhone();
var trimmedAreas = request.AreasOfInterest var trimmedAreas = request.AreasOfInterest
.Select(area => area.Name.Trim()) .Select(area => area.Name.Trim())
@ -39,12 +39,12 @@ public class VolunteerController(IVbytesVolunteerRelayService relayService) : Co
PhoneNumber = normalizedPhone, PhoneNumber = normalizedPhone,
Email = request.Email.Trim(), Email = request.Email.Trim(),
HasApprovedGdpr = request.HasApprovedGdpr!.Value, HasApprovedGdpr = request.HasApprovedGdpr!.Value,
AreasOfInterest = trimmedAreas.Select(name => new AreasOfInterest { Name = name }).ToList() AreasOfInterest = trimmedAreas.Select(name => new AreaOfInterest { Name = name }).ToList()
}; };
var result = await _relayService.RegisterVolunteerAsync(volunteer, cancellationToken); var result = await _relayService.RegisterVolunteerAsync(volunteer, cancellationToken);
if (result.StatusCode == 200 && !result.Message.Contains("401")) if (result.Success)
{ {
return Ok(); return Ok();
} }

View file

@ -16,33 +16,34 @@ var relayOptions = builder.Configuration.GetSection("VbytesRelay").Get<VbytesRel
var certificate = X509CertificateLoader.LoadPkcs12FromFile( var certificate = X509CertificateLoader.LoadPkcs12FromFile(
relayOptions.ClientCertificatePfxPath, password: null); relayOptions.ClientCertificatePfxPath, password: null);
builder.Services.AddSingleton(sp => var authApiOptions = builder.Configuration.GetSection("AuthApi").Get<AuthApiOptions>()
?? throw new InvalidOperationException("AuthApi configuration section is missing.");
builder.Services.Configure<VbytesRelayOptions>(builder.Configuration.GetSection("VbytesRelay"));
builder.Services.AddHttpClient<IVbytesParticipantRelayService, VbytesParticipantRelayService>(o =>
{
o.BaseAddress = new Uri(relayOptions.BaseUrl);
o.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{ {
var handler = new HttpClientHandler(); var handler = new HttpClientHandler();
handler.ClientCertificates.Add(certificate); handler.ClientCertificates.Add(certificate);
return handler; return handler;
}); });
var authApiOptions = builder.Configuration.GetSection("AuthApi").Get<AuthApiOptions>()
?? throw new InvalidOperationException("AuthApi configuration section is missing.");
builder.Services.Configure<VbytesRelayOptions>(builder.Configuration.GetSection("VbytesRelay"));
builder.Services.AddHttpClient<IVbytesParticipantRelayService, VbytesParticipantRelayService>(o =>
{
o.BaseAddress = new Uri(relayOptions.BaseUrl) ??
throw new InvalidOperationException("BaseUrl is missing in VbytesRelay configuration.");
o.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService<HttpClientHandler>());
builder.Services.AddHttpClient<IVbytesVolunteerRelayService, VbytesVolunteerRelayService>(o => builder.Services.AddHttpClient<IVbytesVolunteerRelayService, VbytesVolunteerRelayService>(o =>
{ {
o.BaseAddress = new Uri(relayOptions.BaseUrl); o.BaseAddress = new Uri(relayOptions.BaseUrl);
o.Timeout = TimeSpan.FromSeconds(30); o.Timeout = TimeSpan.FromSeconds(30);
}) })
.ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService<HttpClientHandler>()); .ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(certificate);
return handler;
});
builder.Services.Configure<AuthApiOptions>(builder.Configuration.GetSection("AuthApi")); builder.Services.Configure<AuthApiOptions>(builder.Configuration.GetSection("AuthApi"));
builder.Services.AddHttpClient<IAuthService, AuthService>(o => builder.Services.AddHttpClient<IAuthService, AuthService>(o =>

View file

@ -14,6 +14,8 @@ public class ParticipantRegistrationRequest
public string SurName { get; set; } = string.Empty; public string SurName { get; set; } = string.Empty;
[Required] [Required]
[RegularExpression(@"^([1-9]|Gymnasium [1-3])$",
ErrorMessage = "Grade must be 19 or Gymnasium 13.")]
public string Grade { get; set; } = string.Empty; public string Grade { get; set; } = string.Empty;
public string? PhoneNumber { get; set; } public string? PhoneNumber { get; set; }

View file

@ -5,7 +5,6 @@ namespace Registration.API.Services;
public class AuthService(HttpClient httpClient, IOptions<AuthApiOptions> options) : IAuthService public class AuthService(HttpClient httpClient, IOptions<AuthApiOptions> options) : IAuthService
{ {
public const string HttpClientName = "AuthApi";
private readonly HttpClient _httpClient = httpClient; private readonly HttpClient _httpClient = httpClient;
private readonly AuthApiOptions _options = options.Value; private readonly AuthApiOptions _options = options.Value;

View file

@ -11,8 +11,6 @@ public class VbytesParticipantRelayService(
ILogger<VbytesParticipantRelayService> logger) ILogger<VbytesParticipantRelayService> logger)
: VbytesRelayServiceBase(httpClient, options, logger), IVbytesParticipantRelayService : VbytesRelayServiceBase(httpClient, options, logger), IVbytesParticipantRelayService
{ {
public const string HttpClientName = "VbytesRelay";
private readonly ILogger<VbytesParticipantRelayService> _logger = logger; private readonly ILogger<VbytesParticipantRelayService> _logger = logger;
public async Task<VbytesRelayResult> RegisterParticipantAsync(Participant participant, CancellationToken cancellationToken = default) public async Task<VbytesRelayResult> RegisterParticipantAsync(Participant participant, CancellationToken cancellationToken = default)

View file

@ -16,7 +16,11 @@ public class VbytesVolunteerRelayService(
public async Task<VbytesRelayResult> RegisterVolunteerAsync(Volunteer volunteer, CancellationToken cancellationToken = default) public async Task<VbytesRelayResult> RegisterVolunteerAsync(Volunteer volunteer, CancellationToken cancellationToken = default)
{ {
var configError = ValidateConfiguration(); var configError = ValidateConfiguration();
if (configError is not null) return configError; if (configError is not null)
{
_logger.LogError("Relay configuration invalid: {Message}", configError.Message);
return configError;
}
try try
{ {

View file

@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
namespace Registration.API.Validation;
public static class InputNormalizationExtensions
{
public static string NormalizePhone(this string phone) =>
InputNormalization.NormalizeSwedishMobile(phone);
public static string? NormalizeOptionalPhone(this string? phone) =>
string.IsNullOrWhiteSpace(phone) ? null : InputNormalization.NormalizeSwedishMobile(phone);
public static bool TryNormalizeSwedishSsn(this string ssn, out string normalized, out IActionResult? errorResult)
{
normalized = InputNormalization.NormalizeSsn(ssn);
if (InputNormalization.IsValidSsn(normalized))
{
errorResult = null;
return true;
}
errorResult = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["ssn"] = ["SSN must contain 10 or 12 digits."]
})
{
Title = "One or more validation errors occurred.",
Status = StatusCodes.Status400BadRequest
});
return false;
}
}

View file

@ -1,6 +1,6 @@
namespace Registration.Domain.Models; namespace Registration.Domain.Models;
public class AreasOfInterest public class AreaOfInterest
{ {
public required string Name { get; set; } public required string Name { get; set; }
} }

View file

@ -7,5 +7,5 @@ public class Volunteer
public required string PhoneNumber { get; set; } public required string PhoneNumber { get; set; }
public required string Email { get; set; } public required string Email { get; set; }
public required bool HasApprovedGdpr { get; set; } public required bool HasApprovedGdpr { get; set; }
public required List<AreasOfInterest> AreasOfInterest { get; set; } public required List<AreaOfInterest> AreasOfInterest { get; set; }
} }

View file

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import router from "next/router"; import { useRouter } from "next/navigation";
interface EventContent { interface EventContent {
registrationEnabled: boolean; registrationEnabled: boolean;
@ -29,6 +29,7 @@ const isValidMobileNumber = (value: string) =>
/^07\d{8}$/.test(normalizeMobileNumber(value)); /^07\d{8}$/.test(normalizeMobileNumber(value));
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter();
const [content, setContent] = useState<EventContent | null>(null); const [content, setContent] = useState<EventContent | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: "", firstName: "",
@ -355,15 +356,30 @@ export default function RegisterPage() {
> >
Årskurs * Årskurs *
</label> </label>
<input <select
type="text"
name="grade" name="grade"
id="grade" id="grade"
required required
className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.grade ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`} className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.grade ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.grade} value={formData.grade}
onChange={handleInputChange} onChange={handleInputChange}
/> >
<option value="">Välj årskurs</option>
<optgroup label="Grundskola">
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((g) => (
<option key={g} value={g}>
Årskurs {g}
</option>
))}
</optgroup>
<optgroup label="Gymnasium">
{["1", "2", "3"].map((g) => (
<option key={`gym${g}`} value={`Gymnasium ${g}`}>
Gymnasium {g}
</option>
))}
</optgroup>
</select>
{fieldErrors.grade && ( {fieldErrors.grade && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.grade}</p> <p className="mt-1 text-xs text-red-600">{fieldErrors.grade}</p>
)} )}