Compare commits

...

2 commits

Author SHA1 Message Date
Kruille
9d9c9b3f38 Relay all api calls 2026-02-19 10:54:32 +01:00
Kruille
d951bd625a remove weatherforecast 2026-02-18 11:37:27 +01:00
31 changed files with 698 additions and 57 deletions

107
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,107 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start: Postgres",
"type": "shell",
"command": "docker compose up -d",
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "Start: Registration.API",
"type": "shell",
"command": "dotnet run --project src/Registration/Registration.API --launch-profile https",
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": {
"pattern": {
"regexp": "^$"
},
"background": {
"activeOnStart": true,
"beginsPattern": "Building\\.\\.\\.",
"endsPattern": "Now listening on:"
}
}
},
{
"label": "Start: AuthAPI",
"type": "shell",
"command": "dotnet run --project src/Auth/AuthAPI",
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": {
"pattern": {
"regexp": "^$"
},
"background": {
"activeOnStart": true,
"beginsPattern": "Building\\.\\.\\.",
"endsPattern": "Now listening on:"
}
}
},
{
"label": "Start: Frontend",
"type": "shell",
"command": "npm run dev",
"options": {
"cwd": "${workspaceFolder}/src/Web/lan-frontend"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": {
"pattern": {
"regexp": "^$"
},
"background": {
"activeOnStart": true,
"beginsPattern": ".*",
"endsPattern": "Local:.*localhost"
}
}
},
{
"label": "Start: All",
"dependsOn": [
"Start: Postgres",
"Start: Registration.API",
"Start: AuthAPI",
"Start: Frontend"
],
"dependsOrder": "sequence",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": []
}
]
}

View file

@ -23,3 +23,92 @@ Micro Service For Registering For An Event
### Database ### Database
- Postgres - Postgres
## Configuration
Sensitive values are never stored in source control. Configure them via [user secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) (development) or environment variables (production).
Key-only examples are available here:
- `src/Registration/Registration.API/appsettings.example.json`
- `src/Auth/AuthAPI/appsettings.example.json`
- `src/Web/lan-frontend/.env.example`
### Frontend (Next.js)
Run from `src/Web/lan-frontend`:
```bash
cp .env.example .env.local
```
Set the backend API base URL in `.env.local`:
```env
REGISTRATION_API_URL=http://localhost:5063
```
### Registration.API
Run from `src/Registration/Registration.API`:
```bash
dotnet user-secrets set "VbytesRelay:ApiKey" "<your-api-key>"
dotnet user-secrets set "VbytesRelay:ClientCertificatePfxPath" "<absolute-path-to>.pfx"
dotnet user-secrets set "VbytesRelay:ParticipantRegisterPath" "/api/participant"
dotnet user-secrets set "Security:SsnPepper" "<your-pepper-value>"
```
| Key | Description |
| -------------------------------------- | ------------------------------------------------------------------------------------- |
| `VbytesRelay:ApiKey` | API key for the Vbytes relay service |
| `VbytesRelay:ClientCertificatePfxPath` | Absolute path to the client certificate `.pfx` file |
| `VbytesRelay:ParticipantRegisterPath` | Path for the participant registration endpoint on the relay (e.g. `/api/participant`) |
| `Security:SsnPepper` | Pepper value used when hashing SSNs |
For non-development environments, ensure these environment variables are set:
```bash
ConnectionStrings__DefaultConnection=<postgres-connection-string>
Security__SsnPepper=<pepper-value>
VbytesRelay__BaseUrl=https://api.lan.vbytes.se
VbytesRelay__ParticipantRegisterPath=/api/participant
VbytesRelay__VolunteerRegisterPath=/api/volunteer
VbytesRelay__ApiKeyHeaderName=X-Api-Key
VbytesRelay__ApiKey=<relay-api-key>
VbytesRelay__ClientCertificatePfxPath=<absolute-path-to>.pfx
AuthApi__BaseUrl=<auth-api-base-url>
AuthApi__ValidatePath=/validate
```
> **Note:** ASP.NET Core's configuration priority is `appsettings.json``appsettings.Development.json`**user secrets****environment variables**. Environment variables always win, so any value set in `launchSettings.json` will override user secrets. Secret values (`ApiKey`, cert paths, relay paths) are intentionally absent from `launchSettings.json` to ensure user secrets are respected.
### AuthAPI
Run from `src/Auth/AuthAPI`:
```bash
dotnet user-secrets set "EnvironmentVariables:ApiUrl" "<member-api-url>"
dotnet user-secrets set "EnvironmentVariables:ApiKey" "<member-api-key>"
dotnet user-secrets set "EnvironmentVariables:AssociationNumber" "<association-number>"
```
| Key | Description |
| ---------------------------------------- | ------------------------------------------ |
| `EnvironmentVariables:ApiUrl` | Base URL of the member validation API |
| `EnvironmentVariables:ApiKey` | API key for the member validation API |
| `EnvironmentVariables:AssociationNumber` | Association number used for member lookups |
For non-development environments, ensure these environment variables are set:
```bash
EnvironmentVariables__ApiUrl=<member-api-url>
EnvironmentVariables__ApiKey=<member-api-key>
EnvironmentVariables__AssociationNumber=<association-number>
```
For frontend production configuration, set:
```bash
REGISTRATION_API_URL=<registration-api-base-url>
```

View file

@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>e53e6e14-fe65-43c6-82fd-0f1fb394679d</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,20 +1,22 @@
@AuthAPI_HostAddress = http://localhost:5127 @AuthAPI_HostAddress = http://localhost:5127
GET {{AuthAPI_HostAddress}}/validate/ # Validate by SSN
Accept: application/json POST {{AuthAPI_HostAddress}}/validate
Content-Type: application/json Content-Type: application/json
{ {
"Email": "someValue", "ssn": "8612125643"
"FirstName": "name"
} }
### ###
GET {{AuthAPI_HostAddress}}/validate/ # Validate by email + first name
Accept: application/json POST {{AuthAPI_HostAddress}}/validate
Content-Type: application/json Content-Type: application/json
{ {
"Ssn": "10 or 8 number length" "email": "testwith@validemail.se",
"firstName": "AndCorrectName"
} }
### ###

View file

@ -5,7 +5,7 @@ using AuthAPI;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.Configure<AuthSettings>( builder.Services.Configure<AuthSettings>(
builder.Configuration.GetSection("EnviromentVariables")); builder.Configuration.GetSection("EnvironmentVariables"));
builder.Services.AddScoped<MemberValidationService>(); builder.Services.AddScoped<MemberValidationService>();
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
{ {
@ -14,7 +14,7 @@ builder.Services.ConfigureHttpJsonOptions(options =>
var app = builder.Build(); var app = builder.Build();
app.MapGet("/validate", async ( app.MapPost("/validate", async (
[FromBody] Request validationRequest, [FromBody] Request validationRequest,
HttpClient httpClient, HttpClient httpClient,
MemberValidationService memberService, MemberValidationService memberService,
@ -32,7 +32,8 @@ app.MapGet("/validate", async (
return Results.StatusCode((int)response.StatusCode); return Results.StatusCode((int)response.StatusCode);
var content = await response.Content.ReadAsStringAsync(); var content = await response.Content.ReadAsStringAsync();
return content.Contains("\"member_found\":true,") ? Results.Ok() : Results.NotFound(); Console.WriteLine($"[DEBUG] Sverok response: {content}");
return content.Contains("\"member_found\":true") ? Results.Ok() : Results.NotFound();
}) })
.WithName("ValidateMember"); .WithName("ValidateMember");

View file

@ -5,9 +5,9 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"EnviromentVariables": { "EnvironmentVariables": {
"ApiUrl": "", "ApiUrl": "",
"ApiKey": "", "ApiKey": "",
"AssociationNumber": "" "AssociationNumber": ""
} }
} }

View file

@ -0,0 +1,14 @@
{
"EnvironmentVariables": {
"ApiUrl": "",
"ApiKey": "",
"AssociationNumber": ""
},
"Logging": {
"LogLevel": {
"Default": "",
"Microsoft.AspNetCore": ""
}
},
"AllowedHosts": ""
}

View file

@ -5,10 +5,10 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"EnviromentVariables": { "EnvironmentVariables": {
"ApiUrl": "", "ApiUrl": "",
"ApiKey": "", "ApiKey": "",
"AssociationNumber": "" "AssociationNumber": ""
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View file

@ -0,0 +1,7 @@
namespace Registration.API.Configuration;
public class AuthApiOptions
{
public string BaseUrl { get; set; } = string.Empty;
public string ValidatePath { get; set; } = "/validate";
}

View file

@ -0,0 +1,11 @@
namespace Registration.API.Configuration;
public class VbytesRelayOptions
{
public string BaseUrl { get; set; } = string.Empty;
public string ParticipantRegisterPath { get; set; } = string.Empty;
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";
}

View file

@ -1,16 +1,26 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Registration.API.Services;
using Registration.Domain.Models; using Registration.Domain.Models;
namespace Registration.API.Controllers namespace Registration.API.Controllers
{ {
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
public class ParticipantController : ControllerBase public class ParticipantController(IVbytesParticipantRelayService relayService) : ControllerBase
{ {
private readonly IVbytesParticipantRelayService _relayService = relayService;
[HttpPost("register")] [HttpPost("register")]
public IActionResult RegisterForLan([FromBody] Participant participant) public async Task<IActionResult> RegisterForLan([FromBody] Participant participant, CancellationToken cancellationToken)
{ {
return Ok(); var result = await _relayService.RegisterParticipantAsync(participant, cancellationToken);
if (result.Success)
{
return Ok();
}
return StatusCode(result.StatusCode, new { message = result.Message });
} }
} }
} }

View file

@ -1,26 +1,21 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Registration.API.Services;
using Registration.Infra.Repositories; using Registration.Infra.Repositories;
namespace Registration.API.Controllers namespace Registration.API.Controllers
{ {
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
public class RegistrationController(IMemberRepository memberRepository) : ControllerBase public class RegistrationController(IMemberRepository memberRepository, IAuthService authService) : ControllerBase
{ {
private readonly IMemberRepository _memberRepository = memberRepository; private readonly IMemberRepository _memberRepository = memberRepository;
private readonly IAuthService _authService = authService;
[HttpGet("register/{ssn}")] [HttpGet("register/{ssn}")]
public IActionResult ValidateSsn(string ssn) public async Task<IActionResult> ValidateSsn(string ssn, CancellationToken cancellationToken)
{ {
// Should talk to the auth api to validate the ssn properly. var isMember = await _authService.IsMemberAsync(ssn, cancellationToken);
if (ssn.Length == 10 && long.TryParse(ssn, out _)) return isMember ? Ok() : NotFound();
{
return Ok();
}
else
{
return NotFound();
}
} }
[HttpPost("register/{ssn}")] [HttpPost("register/{ssn}")]

View file

@ -1,16 +1,26 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Registration.API.Services;
using Registration.Domain.Models; using Registration.Domain.Models;
namespace Registration.API.Controllers namespace Registration.API.Controllers
{ {
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
public class VolunteerController : ControllerBase public class VolunteerController(IVbytesVolunteerRelayService relayService) : ControllerBase
{ {
private readonly IVbytesVolunteerRelayService _relayService = relayService;
[HttpPost("register")] [HttpPost("register")]
public IActionResult RegisterVolunteer([FromBody] Volunteer volunteer) public async Task<IActionResult> RegisterVolunteer([FromBody] Volunteer volunteer, CancellationToken cancellationToken)
{ {
return Ok(); var result = await _relayService.RegisterVolunteerAsync(volunteer, cancellationToken);
if (result.Success)
{
return Ok();
}
return StatusCode(result.StatusCode, new { message = result.Message });
} }
} }
} }

View file

@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace Registration.API.DtoModels;
public record ParticipantRelayDto(
[property: JsonPropertyName("member")] bool Member,
[property: JsonPropertyName("first_name")] string FirstName,
[property: JsonPropertyName("surname")] string Surname,
[property: JsonPropertyName("grade")] string Grade,
[property: JsonPropertyName("phone")] string? Phone,
[property: JsonPropertyName("email")] string? Email,
[property: JsonPropertyName("guardian_name")] string GuardianName,
[property: JsonPropertyName("guardian_phone")] string GuardianPhone,
[property: JsonPropertyName("guardian_email")] string GuardianEmail,
[property: JsonPropertyName("is_visiting")] bool IsVisiting,
[property: JsonPropertyName("gdpr")] bool Gdpr,
[property: JsonPropertyName("friends")] string? Friends,
[property: JsonPropertyName("special_diet")] string? SpecialDiet
);

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Registration.API.DtoModels;
public record VolunteerRelayDto(
[property: JsonPropertyName("first_name")] string FirstName,
[property: JsonPropertyName("surname")] string Surname,
[property: JsonPropertyName("phone")] string Phone,
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("gdpr")] bool Gdpr,
[property: JsonPropertyName("areas")] string[] Areas
);

View file

@ -1,10 +1,49 @@
using System.Security.Cryptography.X509Certificates;
using Registration.Infra.Repositories; using Registration.Infra.Repositories;
using Registration.API.Configuration;
using Registration.API.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IMemberRepository, MemberRepository>(); builder.Services.AddScoped<IMemberRepository, MemberRepository>();
var relayOptions = builder.Configuration.GetSection("VbytesRelay").Get<VbytesRelayOptions>()
?? throw new InvalidOperationException("VbytesRelay configuration section is missing.");
builder.Services.Configure<VbytesRelayOptions>(builder.Configuration.GetSection("VbytesRelay"));
var certificate = X509CertificateLoader.LoadPkcs12FromFile(
relayOptions.ClientCertificatePfxPath, password: null);
builder.Services.AddHttpClient(VbytesParticipantRelayService.HttpClientName, client =>
{
client.BaseAddress = new Uri(relayOptions.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(certificate);
return handler;
});
builder.Services.AddScoped<IVbytesParticipantRelayService, VbytesParticipantRelayService>();
builder.Services.AddScoped<IVbytesVolunteerRelayService, VbytesVolunteerRelayService>();
var authApiOptions = builder.Configuration.GetSection("AuthApi").Get<AuthApiOptions>()
?? throw new InvalidOperationException("AuthApi configuration section is missing.");
builder.Services.Configure<AuthApiOptions>(builder.Configuration.GetSection("AuthApi"));
builder.Services.AddHttpClient(AuthService.HttpClientName, client =>
{
client.BaseAddress = new Uri(authApiOptions.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddControllers(); builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
@ -22,7 +61,11 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseHttpsRedirection(); if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();

View file

@ -7,7 +7,9 @@
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "http://localhost:5063", "applicationUrl": "http://localhost:5063",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development",
"VbytesRelay__BaseUrl": "https://api.lan.vbytes.se",
"VbytesRelay__ApiKeyHeaderName": "X-Api-Key"
} }
}, },
"https": { "https": {
@ -16,8 +18,10 @@
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "https://localhost:7209;http://localhost:5063", "applicationUrl": "https://localhost:7209;http://localhost:5063",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development",
"VbytesRelay__BaseUrl": "https://api.lan.vbytes.se",
"VbytesRelay__ApiKeyHeaderName": "X-Api-Key"
} }
} }
} }
} }

View file

@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>92929106-8d00-401d-8f20-f372f475ea39</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,6 +1,47 @@
@RegistrationAPI_HostAddress = http://localhost:5063 @RegistrationAPI_HostAddress = http://localhost:5063
GET {{RegistrationAPI_HostAddress}}/weatherforecast/ ### Clear all registrations
Accept: application/json DELETE {{RegistrationAPI_HostAddress}}/api/Registration/clear
### ###
POST {{RegistrationAPI_HostAddress}}/api/participant/register
Content-Type: application/json
{
"isMember": true,
"firstName": "Test",
"surName": "User",
"grade": "9",
"phoneNumber": "0700000000",
"email": "test.user@example.com",
"guardianName": "Guardian User",
"guardianPhoneNumber": "0700000001",
"guardianEmail": "guardian.user@example.com",
"isVisitor": false,
"hasApprovedGdpr": true,
"friends": "Friend One",
"specialDiet": "None"
}
### Register volunteer
POST {{RegistrationAPI_HostAddress}}/api/volunteer/register
Content-Type: application/json
{
"firstName": "Jane",
"surName": "Doe",
"phoneNumber": "0700123456",
"email": "email@email.com",
"hasApprovedGdpr": true,
"areasOfInterest": [
{
"name": "Städ"
},
{
"name": "Kiosk"
}
]
}

View file

@ -0,0 +1,22 @@
using Microsoft.Extensions.Options;
using Registration.API.Configuration;
namespace Registration.API.Services;
public class AuthService(IHttpClientFactory httpClientFactory, IOptions<AuthApiOptions> options) : IAuthService
{
public const string HttpClientName = "AuthApi";
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
private readonly AuthApiOptions _options = options.Value;
public async Task<bool> IsMemberAsync(string ssn, CancellationToken cancellationToken = default)
{
var client = _httpClientFactory.CreateClient(HttpClientName);
var response = await client.PostAsJsonAsync(
_options.ValidatePath,
new { ssn },
cancellationToken);
return response.IsSuccessStatusCode;
}
}

View file

@ -0,0 +1,6 @@
namespace Registration.API.Services;
public interface IAuthService
{
Task<bool> IsMemberAsync(string ssn, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,10 @@
using Registration.Domain.Models;
namespace Registration.API.Services;
public interface IVbytesParticipantRelayService
{
Task<VbytesRelayResult> RegisterParticipantAsync(Participant participant, CancellationToken cancellationToken = default);
}
public sealed record VbytesRelayResult(bool Success, int StatusCode, string Message);

View file

@ -0,0 +1,8 @@
using Registration.Domain.Models;
namespace Registration.API.Services;
public interface IVbytesVolunteerRelayService
{
Task<VbytesRelayResult> RegisterVolunteerAsync(Volunteer volunteer, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,63 @@
using Microsoft.Extensions.Options;
using Registration.API.Configuration;
using Registration.API.DtoModels;
using Registration.Domain.Models;
namespace Registration.API.Services;
public class VbytesParticipantRelayService(
IHttpClientFactory httpClientFactory,
IOptions<VbytesRelayOptions> options,
ILogger<VbytesParticipantRelayService> logger)
: VbytesRelayServiceBase(httpClientFactory, options), IVbytesParticipantRelayService
{
public const string HttpClientName = "VbytesRelay";
private readonly ILogger<VbytesParticipantRelayService> _logger = logger;
public async Task<VbytesRelayResult> RegisterParticipantAsync(Participant participant, CancellationToken cancellationToken = default)
{
var configError = ValidateConfiguration();
if (configError is not null)
{
_logger.LogWarning("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);
return new VbytesRelayResult(false, StatusCodes.Status504GatewayTimeout, "Upstream timeout.");
}
catch (Exception ex)
{
_logger.LogError(ex, "VBytes participant relay exception.");
return new VbytesRelayResult(false, StatusCodes.Status502BadGateway, "Upstream relay failed.");
}
}
private static ParticipantRelayDto Map(Participant participant) => new(
Member: participant.IsMember,
FirstName: participant.FirstName,
Surname: participant.SurName,
Grade: participant.Grade,
Phone: participant.PhoneNumber,
Email: participant.Email,
GuardianName: participant.GuardianName,
GuardianPhone: participant.GuardianPhoneNumber,
GuardianEmail: participant.GuardianEmail,
IsVisiting: participant.IsVisitor,
Gdpr: participant.HasApprovedGdpr,
Friends: participant.Friends,
SpecialDiet: participant.SpecialDiet
);
}

View file

@ -0,0 +1,80 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Options;
using Registration.API.Configuration;
namespace Registration.API.Services;
public abstract class VbytesRelayServiceBase(
IHttpClientFactory httpClientFactory,
IOptions<VbytesRelayOptions> options)
{
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
protected readonly VbytesRelayOptions Options = options.Value;
protected async Task<VbytesRelayResult> SendAsync(string path, object payload, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient(VbytesParticipantRelayService.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, BuildPath(path))
{
Content = JsonContent.Create(payload)
};
request.Headers.TryAddWithoutValidation(Options.ApiKeyHeaderName, Options.ApiKey);
using var response = await client.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($"[DEBUG] Relay success body: {body}");
return new VbytesRelayResult(true, StatusCodes.Status200OK, "Request relayed successfully.");
}
var statusCode = (int)response.StatusCode;
Console.WriteLine($"[DEBUG] Relay error body: {body}");
var message = body.Length > 600 ? body[..600] : body;
return new VbytesRelayResult(false, statusCode, string.IsNullOrWhiteSpace(message) ? "Upstream request failed." : message);
}
protected VbytesRelayResult? ValidateConfiguration()
{
if (string.IsNullOrWhiteSpace(Options.BaseUrl))
{
return new VbytesRelayResult(false, StatusCodes.Status500InternalServerError, "Relay base URL is not configured.");
}
if (!IsConfigured(Options.ApiKey))
{
return new VbytesRelayResult(false, StatusCodes.Status500InternalServerError, "Relay API key is not configured.");
}
return null;
}
private static string BuildPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "/";
}
return path.StartsWith('/') ? path : $"/{path}";
}
private static async Task<string> 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);
}
}

View file

@ -0,0 +1,44 @@
using Microsoft.Extensions.Options;
using Registration.API.Configuration;
using Registration.API.DtoModels;
using Registration.Domain.Models;
namespace Registration.API.Services;
public class VbytesVolunteerRelayService(
IHttpClientFactory httpClientFactory,
IOptions<VbytesRelayOptions> options,
ILogger<VbytesVolunteerRelayService> logger)
: VbytesRelayServiceBase(httpClientFactory, options), IVbytesVolunteerRelayService
{
private readonly ILogger<VbytesVolunteerRelayService> _logger = logger;
public async Task<VbytesRelayResult> RegisterVolunteerAsync(Volunteer volunteer, CancellationToken cancellationToken = default)
{
var configError = ValidateConfiguration();
if (configError is not null) return configError;
try
{
return await SendAsync(Options.VolunteerRegisterPath, Map(volunteer), cancellationToken);
}
catch (TaskCanceledException)
{
return new VbytesRelayResult(false, StatusCodes.Status504GatewayTimeout, "Upstream timeout.");
}
catch (Exception ex)
{
_logger.LogError(ex, "VBytes volunteer relay exception.");
return new VbytesRelayResult(false, StatusCodes.Status502BadGateway, "Upstream relay failed.");
}
}
private static VolunteerRelayDto Map(Volunteer volunteer) => new(
FirstName: volunteer.FirstName,
Surname: volunteer.SurName,
Phone: volunteer.PhoneNumber,
Email: volunteer.Email,
Gdpr: volunteer.HasApprovedGdpr,
Areas: volunteer.AreasOfInterest.Select(area => area.Name).ToArray()
);
}

View file

@ -1,13 +0,0 @@
namespace RegistrationAPI;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}

View file

@ -9,6 +9,18 @@
"DefaultConnection": "Host=localhost;Username=postgres;Password=postgres;Database=postgres;Port=5432" "DefaultConnection": "Host=localhost;Username=postgres;Password=postgres;Database=postgres;Port=5432"
}, },
"Security": { "Security": {
"SsnPepper": "VBYTES_LAN_2026_SECRET_PEPPER" "SsnPepper": "__SET_IN_USER_SECRETS__"
},
"VbytesRelay": {
"BaseUrl": "https://api.lan.vbytes.se",
"ParticipantRegisterPath": "/api/participant",
"VolunteerRegisterPath": "/api/volunteer/register",
"ApiKeyHeaderName": "X-Api-Key",
"ApiKey": "",
"ClientCertificatePfxPath": ""
},
"AuthApi": {
"BaseUrl": "http://localhost:5127",
"ValidatePath": "/validate"
} }
} }

View file

@ -0,0 +1,30 @@
{
"ConnectionStrings": {
"DefaultConnection": ""
},
"Security": {
"SsnPepper": ""
},
"Admin": {
"Password": ""
},
"VbytesRelay": {
"BaseUrl": "",
"ParticipantRegisterPath": "/api/participant",
"VolunteerRegisterPath": "",
"ApiKeyHeaderName": "",
"ApiKey": "",
"ClientCertificatePfxPath": ""
},
"AuthApi": {
"BaseUrl": "",
"ValidatePath": "/validate"
},
"Logging": {
"LogLevel": {
"Default": "",
"Microsoft.AspNetCore": ""
}
},
"AllowedHosts": ""
}

View file

@ -3,11 +3,23 @@
"DefaultConnection": "Host=localhost;Username=postgres;Password=postgres;Database=postgres;Port=5432" "DefaultConnection": "Host=localhost;Username=postgres;Password=postgres;Database=postgres;Port=5432"
}, },
"Security": { "Security": {
"SsnPepper": "VBYTES_LAN_2026_SECRET_PEPPER" "SsnPepper": "__SET_IN_USER_SECRETS__"
}, },
"Admin": { "Admin": {
"Password": "admin" "Password": "admin"
}, },
"VbytesRelay": {
"BaseUrl": "https://api.lan.vbytes.se",
"ParticipantRegisterPath": "/api/participant",
"VolunteerRegisterPath": "/api/volunteer/register",
"ApiKeyHeaderName": "X-Api-Key",
"ApiKey": "__SET_IN_USER_SECRETS__",
"ClientCertificatePfxPath": "__SET_IN_USER_SECRETS__"
},
"AuthApi": {
"BaseUrl": "",
"ValidatePath": "/validate"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@ -15,4 +27,4 @@
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }

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 AreasOfInterest AreasOfInterest { get; set; } public required List<AreasOfInterest> AreasOfInterest { get; set; }
} }