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")]
public async Task<IActionResult> 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 normalizedGuardianPhone = request.GuardianPhoneNumber.NormalizePhone();
var normalizedParticipantPhone = request.PhoneNumber.NormalizeOptionalPhone();
var participant = new Participant
{
@ -42,7 +37,7 @@ public class ParticipantController(IVbytesParticipantRelayService relayService)
var result = await _relayService.RegisterParticipantAsync(participant, cancellationToken);
if (result.StatusCode == 200 && !result.Message.Contains("401"))
if (result.Success)
{
return Ok();
}

View file

@ -15,9 +15,9 @@ namespace Registration.API.Controllers
[HttpGet("register/{ssn}")]
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);
@ -25,11 +25,11 @@ namespace Registration.API.Controllers
}
[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);
@ -40,9 +40,9 @@ namespace Registration.API.Controllers
[HttpGet("registered/{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);
@ -50,30 +50,12 @@ namespace Registration.API.Controllers
}
[HttpDelete("clear")]
public async Task<IActionResult> ClearRegistrations()
public async Task<IActionResult> ClearRegistrations(CancellationToken cancellationToken)
{
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<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")]
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
.Select(area => area.Name.Trim())
@ -39,12 +39,12 @@ public class VolunteerController(IVbytesVolunteerRelayService relayService) : Co
PhoneNumber = normalizedPhone,
Email = request.Email.Trim(),
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);
if (result.StatusCode == 200 && !result.Message.Contains("401"))
if (result.Success)
{
return Ok();
}

View file

@ -16,33 +16,34 @@ var relayOptions = builder.Configuration.GetSection("VbytesRelay").Get<VbytesRel
var certificate = X509CertificateLoader.LoadPkcs12FromFile(
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();
handler.ClientCertificates.Add(certificate);
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 =>
{
o.BaseAddress = new Uri(relayOptions.BaseUrl);
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.AddHttpClient<IAuthService, AuthService>(o =>

View file

@ -14,6 +14,8 @@ public class ParticipantRegistrationRequest
public string SurName { get; set; } = string.Empty;
[Required]
[RegularExpression(@"^([1-9]|Gymnasium [1-3])$",
ErrorMessage = "Grade must be 19 or Gymnasium 13.")]
public string Grade { get; set; } = string.Empty;
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 const string HttpClientName = "AuthApi";
private readonly HttpClient _httpClient = httpClient;
private readonly AuthApiOptions _options = options.Value;

View file

@ -11,8 +11,6 @@ public class VbytesParticipantRelayService(
ILogger<VbytesParticipantRelayService> logger)
: VbytesRelayServiceBase(httpClient, options, logger), IVbytesParticipantRelayService
{
public const string HttpClientName = "VbytesRelay";
private readonly ILogger<VbytesParticipantRelayService> _logger = logger;
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)
{
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
{

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;
public class AreasOfInterest
public class AreaOfInterest
{
public required string Name { get; set; }
}

View file

@ -7,5 +7,5 @@ public class Volunteer
public required string PhoneNumber { get; set; }
public required string Email { 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 Link from "next/link";
import router from "next/router";
import { useRouter } from "next/navigation";
interface EventContent {
registrationEnabled: boolean;
@ -29,6 +29,7 @@ const isValidMobileNumber = (value: string) =>
/^07\d{8}$/.test(normalizeMobileNumber(value));
export default function RegisterPage() {
const router = useRouter();
const [content, setContent] = useState<EventContent | null>(null);
const [formData, setFormData] = useState({
firstName: "",
@ -355,15 +356,30 @@ export default function RegisterPage() {
>
Årskurs *
</label>
<input
type="text"
<select
name="grade"
id="grade"
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"}`}
value={formData.grade}
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 && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.grade}</p>
)}