Initial import

This commit is contained in:
Stein Helge Riise
2025-11-17 08:32:46 +01:00
commit ede31fbb7e
129 changed files with 9514 additions and 0 deletions
@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Http.HttpResults;
using MinAttest.Contracts.Attests;
using MinAttest.Contracts.Employers;
using MediatR;
using MinAttest.Application.Features.Employers.Commands;
using MinAttest.Application.Features.Employers.Queries;
using MinAttest.Application.Features.Attests.Commands;
using MinAttest.Application.Features.Attests.Queries;
namespace MinAttest.Api.Features.Employer;
public static class Employers
{
public static RouteGroupBuilder MapEmployer(this RouteGroupBuilder group)
{
var employers = group.MapGroup("/employers");
employers.MapPost("/", UpsertEmployer)
.WithName("UpsertEmployer")
.WithOpenApi();
employers.MapGet("/", ListEmployers)
.WithName("ListEmployers")
.WithOpenApi();
employers.MapGet("/{id:guid}", GetEmployer)
.WithName("GetEmployer")
.WithOpenApi();
employers.MapGet("/{employerId:guid}/users", ListEmployerUsers)
.WithName("ListEmployerUsers")
.WithOpenApi();
employers.MapGet("/{employerId:guid}/users/{userId:guid}", GetEmployerUser)
.WithName("GetEmployerUser")
.WithOpenApi();
employers.MapPost("/{employerId:guid}/users", UpsertEmployerUser)
.WithName("UpsertEmployerUser")
.WithOpenApi();
employers.MapDelete("/{employerId:guid}/users/{userId:guid}", DeleteEmployerUser)
.WithName("DeleteEmployerUser")
.WithOpenApi();
employers.MapPost("/{id:guid}/attests", IssueAttestForEmployer)
.WithName("IssueAttestForEmployer")
.WithOpenApi();
employers.MapGet("/{id:guid}/attests", ListAttestsForEmployer)
.WithName("ListAttestsForEmployer")
.WithOpenApi();
employers.MapGet("/{id:guid}/attests/{attestId:guid}/download", DownloadAttestForEmployer)
.WithName("DownloadAttestForEmployer")
.WithOpenApi();
return group;
}
private static async Task<IResult> IssueAttestForEmployer(Guid id, EmployerAttestUploadRequest req, IMediator mediator, CancellationToken ct)
{
var createdId = await mediator.Send(new EmployerIssueAttestCommand(id, req), ct);
if (createdId is null) return Results.NotFound();
return Results.Created($"/api/v1/employers/{id}/attests/{createdId}", new { attestId = createdId });
}
private static async Task<IResult> ListAttestsForEmployer(Guid id, int? take, IMediator mediator, CancellationToken ct)
{
var items = await mediator.Send(new ListEmployerAttestsQuery(id, take), ct);
return Results.Ok(items);
}
private static async Task<IResult> DownloadAttestForEmployer(Guid id, Guid attestId, bool? inline, IMediator mediator, CancellationToken ct)
{
var content = await mediator.Send(new GetAttestContentQuery(attestId), ct);
if (content is null) return Results.NotFound();
var contentType = NormalizeContentType(content.Content, content.ContentType);
var fileName = NormalizeFileName(content.FileName, attestId, contentType);
if (inline == true)
{
return Results.File(content.Content, contentType);
}
return Results.File(content.Content, contentType, fileName);
}
private static string NormalizeContentType(byte[] data, string? original)
{
var ct = string.IsNullOrWhiteSpace(original) || string.Equals(original, "application/octet-stream", StringComparison.OrdinalIgnoreCase)
? null
: original;
if (ct is null)
{
if (data is { Length: >= 4 } && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46)
{
return "application/pdf";
}
}
return ct ?? "application/pdf";
}
private static string NormalizeFileName(string? name, Guid attestId, string contentType)
{
var n = string.IsNullOrWhiteSpace(name) ? $"attest-{attestId}" : name!;
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase) && !n.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
n += ".pdf";
}
return n;
}
private static async Task<IResult> UpsertEmployer(EmployerUpsertRequest req, IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new UpsertEmployerCommand(req.OrgNumber, req.Name), ct);
return Results.Ok(resp);
}
private static async Task<IResult> ListEmployers(IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new ListEmployersQuery(), ct);
return Results.Ok(resp);
}
private static async Task<Results<Ok<EmployerResponse>, NotFound>> GetEmployer(Guid id, IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new GetEmployerQuery(id), ct);
if (resp is null) return TypedResults.NotFound();
return TypedResults.Ok(resp);
}
private static async Task<IResult> ListEmployerUsers(Guid employerId, IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new ListEmployerUsersQuery(employerId), ct);
return Results.Ok(resp);
}
private static async Task<Results<Ok<EmployerUserResponse>, NotFound>> GetEmployerUser(Guid employerId, Guid userId, IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new GetEmployerUserQuery(employerId, userId), ct);
if (resp is null) return TypedResults.NotFound();
return TypedResults.Ok(resp);
}
private static async Task<Results<Ok<EmployerUserResponse>, NotFound, BadRequest<string>>> UpsertEmployerUser(Guid employerId, EmployerUserUpsertRequest req, IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new UpsertEmployerUserCommand(employerId, req.ExternalObjectId, req.Email, req.Name, req.Role), ct);
if (resp is null) return TypedResults.NotFound();
return TypedResults.Ok(resp);
}
private static async Task<Results<NoContent, NotFound>> DeleteEmployerUser(Guid employerId, Guid userId, IMediator mediator, CancellationToken ct)
{
var ok = await mediator.Send(new DeleteEmployerUserCommand(employerId, userId), ct);
if (!ok) return TypedResults.NotFound();
return TypedResults.NoContent();
}
}
@@ -0,0 +1,27 @@
namespace MinAttest.Api.Features.Health;
using MinAttest.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
public static class Health
{
public static RouteGroupBuilder MapHealth(this RouteGroupBuilder group)
{
group.MapGet("/health", GetHealth)
.WithName("Health")
.WithOpenApi();
return group;
}
private static async Task<IResult> GetHealth(AppDbContext db, CancellationToken ct)
{
var canConnect = await db.Database.CanConnectAsync(ct);
var payload = new
{
status = canConnect ? "OK" : "DEGRADED",
database = canConnect ? "Up" : "Down",
time = DateTimeOffset.UtcNow
};
return canConnect ? Results.Ok(payload) : Results.Json(payload, statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Http.HttpResults;
using MinAttest.Contracts.Persons;
using MinAttest.Contracts.Attests;
using MediatR;
using MinAttest.Application.Features.Persons.Commands;
using MinAttest.Application.Features.Persons.Queries;
using MinAttest.Application.Features.Attests.Commands;
using MinAttest.Application.Features.Attests.Queries;
namespace MinAttest.Api.Features.Persons;
public static class Persons
{
public static RouteGroupBuilder MapPersons(this RouteGroupBuilder group)
{
var persons = group.MapGroup("/persons");
persons.MapPost("/", UpsertPerson)
.WithName("UpsertPerson")
.WithOpenApi();
persons.MapGet("/{id:guid}", GetPerson)
.WithName("GetPerson")
.WithOpenApi();
persons.MapPost("/{id:guid}/attests", UploadAttestForPerson)
.WithName("UploadAttestForPerson")
.WithOpenApi();
persons.MapGet("/{id:guid}/attests", ListAttestsForPerson)
.WithName("ListAttestsForPerson")
.WithOpenApi();
persons.MapGet("/{id:guid}/attests/{attestId:guid}/download", DownloadAttestForPerson)
.WithName("DownloadAttestForPerson")
.WithOpenApi();
return group;
}
private static async Task<IResult> UpsertPerson(PersonUpsertRequest req, IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new UpsertPersonCommand(req.NationalIdHash, req.Email, req.Phone), ct);
return Results.Ok(resp);
}
private static async Task<Results<Ok<PersonResponse>, NotFound>> GetPerson(Guid id, IMediator mediator, CancellationToken ct)
{
var resp = await mediator.Send(new GetPersonQuery(id), ct);
if (resp is null) return TypedResults.NotFound();
return TypedResults.Ok(resp);
}
private static async Task<IResult> UploadAttestForPerson(Guid id, PersonAttestUploadRequest req, IMediator mediator, CancellationToken ct)
{
var createdId = await mediator.Send(new PersonUploadAttestCommand(id, req), ct);
if (createdId is null) return Results.NotFound();
return Results.Created($"/api/v1/persons/{id}/attests/{createdId}", new { attestId = createdId });
}
private static async Task<IResult> ListAttestsForPerson(Guid id, int? take, IMediator mediator, CancellationToken ct)
{
var items = await mediator.Send(new ListPersonAttestsQuery(id, take), ct);
return Results.Ok(items);
}
private static async Task<IResult> DownloadAttestForPerson(Guid id, Guid attestId, bool? inline, IMediator mediator, CancellationToken ct)
{
var content = await mediator.Send(new GetAttestContentQuery(attestId), ct);
if (content is null) return Results.NotFound();
var contentType = NormalizeContentType(content.Content, content.ContentType);
var fileName = NormalizeFileName(content.FileName, attestId, contentType);
if (inline == true)
{
// Inline view: omit download filename to avoid attachment disposition
return Results.File(content.Content, contentType);
}
return Results.File(content.Content, contentType, fileName);
}
private static string NormalizeContentType(byte[] data, string? original)
{
var ct = string.IsNullOrWhiteSpace(original) || string.Equals(original, "application/octet-stream", StringComparison.OrdinalIgnoreCase)
? null
: original;
// Sniff PDF signature: %PDF-
if (ct is null)
{
if (data is { Length: >= 4 } && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46)
{
return "application/pdf";
}
}
return ct ?? "application/pdf"; // prefer PDF when unknown
}
private static string NormalizeFileName(string? name, Guid attestId, string contentType)
{
var n = string.IsNullOrWhiteSpace(name) ? $"attest-{attestId}" : name!;
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase) && !n.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
n += ".pdf";
}
return n;
}
}
@@ -0,0 +1,47 @@
using MediatR;
using MinAttest.Application.Features.ShareLinks.Commands;
using MinAttest.Application.Features.ShareLinks.Queries;
using MinAttest.Contracts.ShareLinks;
namespace MinAttest.Api.Features.ShareLinks;
public static class ShareLinks
{
public static RouteGroupBuilder MapShareLinks(this RouteGroupBuilder group)
{
group.MapPost("/attests/{id:guid}/sharelinks", CreateShareLink)
.WithName("CreateShareLinks")
.WithOpenApi();
group.MapGet("/attests/{id:guid}/sharelinks", ListShareLinks)
.WithName("ListShareLinks")
.WithOpenApi();
group.MapDelete("/attests/{id:guid}/sharelinks/{shareLinkId:guid}", RevokeShareLink)
.WithName("RevokeShareLinks")
.WithOpenApi();
return group;
}
private static async Task<IResult> CreateShareLink(Guid id, CreateShareLinkRequest req, HttpContext http, IMediator mediator, CancellationToken ct)
{
var baseUrl = $"{http.Request.Scheme}://{http.Request.Host}";
var resp = await mediator.Send(new CreateShareLinkCommand(id, req, baseUrl), ct);
if (resp is null) return Results.NotFound();
return Results.Ok(resp);
}
private static async Task<IResult> ListShareLinks(Guid id, IMediator mediator, CancellationToken ct)
{
var items = await mediator.Send(new ListShareLinksQuery(id), ct);
return Results.Ok(items);
}
private static async Task<IResult> RevokeShareLink(Guid id, Guid shareLinkId, IMediator mediator, CancellationToken ct)
{
var ok = await mediator.Send(new RevokeShareLinkCommand(id, shareLinkId), ct);
if (!ok) return Results.NotFound();
return Results.NoContent();
}
}