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();
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MinAttest.Api</RootNamespace>
<AssemblyName>MinAttest.Api</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.*" />
<PackageReference Include="Serilog.AspNetCore" Version="9.*" />
<PackageReference Include="Serilog.Sinks.File" Version="7.*" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.*" />
<PackageReference Include="MediatR" Version="12.*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.*" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\MinAttest.ServiceDefaults\MinAttest.ServiceDefaults.csproj" />
<ProjectReference Include="..\MinAttest.Infrastructure\MinAttest.Infrastructure.csproj" />
<ProjectReference Include="..\MinAttest.Domain\MinAttest.Domain.csproj" />
<ProjectReference Include="..\MinAttest.Application\MinAttest.Application.csproj" />
<ProjectReference Include="..\MinAttest.Contracts\MinAttest.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,75 @@
@host = http://localhost:5072
### Health
GET {{host}}/api/v1/health
### Persons
POST {{host}}/api/v1/persons
Content-Type: application/json
{
"nationalIdHash": "hash123",
"email": "user@example.com",
"phone": "+4712345678"
}
### Employers
POST {{host}}/api/v1/employers
Content-Type: application/json
{
"orgNumber": "123456789",
"name": "Bedrift AS"
}
### Attests - list
GET {{host}}/api/v1/persons/{{personId}}/attests
### Person - upload attest (inline content)
POST {{host}}/api/v1/persons/{{personId}}/attests
Content-Type: application/json
{
"title": "Utvikler",
"from": "2021-01-01",
"to": "2023-12-31",
"summary": "Arbeidet med .NET",
"blobPath": "",
"blobHash": null,
"contentBase64": "<base64>",
"contentType": "application/pdf"
}
### Person - download attest
GET {{host}}/api/v1/persons/{{personId}}/attests/{{attestId}}/download
### Employer - upsert employer
POST {{host}}/api/v1/employers
Content-Type: application/json
{
"orgNumber": "123456789",
"name": "Bedrift AS"
}
### Employer - issue attest for person
POST {{host}}/api/v1/employers/{{employerId}}/attests
Content-Type: application/json
{
"personId": "{{personId}}",
"title": "Konsulent",
"from": "2022-01-01",
"to": "2024-01-01",
"summary": "Prosjektarbeid",
"blobPath": "",
"blobHash": null,
"contentBase64": "<base64>",
"contentType": "application/pdf"
}
### Employer - list attests
GET {{host}}/api/v1/employers/{{employerId}}/attests
### Employer - download attest
GET {{host}}/api/v1/employers/{{employerId}}/attests/{{attestId}}/download
+132
View File
@@ -0,0 +1,132 @@
using Microsoft.EntityFrameworkCore;
using Serilog;
using Microsoft.OpenApi.Models;
using MinAttest.Api.Features.Employer;
using MinAttest.Api.Features.Health;
using MinAttest.Api.Features.ShareLinks;
using MinAttest.Infrastructure.Data;
using MinAttest.Api.Features.Persons;
using MediatR;
using MinAttest.Application;
using MinAttest.Application.Abstractions;
using FluentValidation;
using MinAttest.Application.Common.Behaviors;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
Directory.CreateDirectory("logs");
builder.Host.UseSerilog((ctx, services, cfg) =>
{
cfg.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext();
});
builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "MinAttest API",
Version = "v1"
});
});
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ApplicationAssembly).Assembly));
builder.Services.AddScoped<IAppDbContext, MinAttest.Infrastructure.Data.AppDbContext>();
builder.Services.AddValidatorsFromAssemblyContaining<ApplicationAssembly>();
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddDbContext<AppDbContext>(options =>
{
var cs = builder.Configuration.GetConnectionString("Default")
?? builder.Configuration.GetConnectionString("MinAttest")
?? builder.Configuration.GetConnectionString("minattest")
?? throw new InvalidOperationException("Missing connection string. Provide ConnectionStrings:Default or :MinAttest.");
options.UseSqlServer(cs, sql => sql.EnableRetryOnFailure());
});
// Add DB health check to readiness probe
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>(name: "database");
builder.Services.AddCors(options =>
{
options.AddPolicy("Frontend", policy =>
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
if (origins.Length > 0)
{
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
}
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
// Use relative path so it works with PathBase/proxies
c.SwaggerEndpoint("../openapi/v1.json", "MinAttest API v1");
c.RoutePrefix = "swagger";
});
}
app.UseHttpsRedirection();
app.UseCors("Frontend");
app.UseExceptionHandler(errApp =>
{
errApp.Run(async context =>
{
var feature = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
var ex = feature?.Error;
var status = ex is FluentValidation.ValidationException ? StatusCodes.Status400BadRequest : StatusCodes.Status500InternalServerError;
var problem = new
{
type = status == 400 ? "https://datatracker.ietf.org/doc/html/rfc7807#section-3.1" : "about:blank",
title = status == 400 ? "One or more validation errors occurred." : "An unexpected error occurred.",
status,
detail = app.Environment.IsDevelopment() ? ex?.Message : null,
traceId = context.TraceIdentifier,
errors = ex is FluentValidation.ValidationException vex
? vex.Errors.GroupBy(e => e.PropertyName).ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray())
: null
};
context.Response.StatusCode = status;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problem);
});
});
var apiV1 = app.MapGroup("/api/v1");
apiV1.MapHealth();
apiV1.MapEmployer();
apiV1.MapPersons();
apiV1.MapShareLinks();
app.MapDefaultEndpoints();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var c = db.Database.GetDbConnection();
db.Database.Migrate();
}
app.Run();
public partial class Program { }
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7172;http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,39 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Default": "Server=127.0.0.1,14335;Database=minattest-dev-sql;User Id=sa;Password=Your_password123;TrustServerCertificate=True;"
},
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://localhost:3000"
]
},
"Serilog": {
"Using": [ "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "logs/api-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [ "FromLogContext" ]
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Abstractions;
public interface IAppDbContext
{
DbSet<Person> Persons { get; }
DbSet<Employer> Employers { get; }
DbSet<Attest> Attests { get; }
DbSet<ShareLink> ShareLinks { get; }
DbSet<AuditLog> AuditLogs { get; }
DbSet<EmployerUser> EmployerUsers { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,4 @@
namespace MinAttest.Application;
// Marker type for scanning application assembly (MediatR, validators)
public sealed class ApplicationAssembly { }
@@ -0,0 +1,31 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace MinAttest.Application.Common.Behaviors;
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
var sw = Stopwatch.StartNew();
try
{
logger.LogInformation("Handling {RequestName}", requestName);
var response = await next();
sw.Stop();
logger.LogInformation("Handled {RequestName} in {ElapsedMs} ms", requestName, sw.ElapsedMilliseconds);
return response;
}
catch (Exception ex)
{
sw.Stop();
logger.LogError(ex, "Error handling {RequestName} after {ElapsedMs} ms", requestName, sw.ElapsedMilliseconds);
throw;
}
}
}
@@ -0,0 +1,29 @@
using FluentValidation;
using MediatR;
namespace MinAttest.Application.Common.Behaviors;
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count != 0)
{
throw new ValidationException(failures);
}
return await next();
}
}
@@ -0,0 +1,62 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Attests;
using MinAttest.Application.Abstractions;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Attests.Commands;
public record CompleteUploadCommand(Guid UploadId, CompleteUploadRequest Request) : IRequest<Guid?>;
public class CompleteUploadCommandHandler(IAppDbContext db) : IRequestHandler<CompleteUploadCommand, Guid?>
{
public async Task<Guid?> Handle(CompleteUploadCommand request, CancellationToken cancellationToken)
{
var r = request.Request;
var personExists = await db.Persons.AnyAsync(p => p.Id == r.PersonId, cancellationToken);
if (!personExists) return null;
if (r.EmployerId is Guid eid)
{
var exists = await db.Employers.AnyAsync(e => e.Id == eid, cancellationToken);
if (!exists) return null;
}
byte[]? content = null;
long? length = null;
if (!string.IsNullOrWhiteSpace(r.ContentBase64))
{
try
{
content = Convert.FromBase64String(r.ContentBase64);
length = content.LongLength;
}
catch (FormatException)
{
// invalid base64 -> treat as bad request
return null;
}
}
var a = new Attest
{
Id = Guid.NewGuid(),
PersonId = r.PersonId,
EmployerId = r.EmployerId,
Title = r.Title,
From = r.From,
To = r.To,
Summary = r.Summary,
BlobPath = r.BlobPath,
BlobHash = r.BlobHash,
Content = content,
ContentType = r.ContentType,
ContentLength = length,
IssuedAt = DateTimeOffset.UtcNow
};
db.Attests.Add(a);
await db.SaveChangesAsync(cancellationToken);
return a.Id;
}
}
@@ -0,0 +1,23 @@
using FluentValidation;
namespace MinAttest.Application.Features.Attests.Commands;
public class CompleteUploadCommandValidator : AbstractValidator<CompleteUploadCommand>
{
public CompleteUploadCommandValidator()
{
RuleFor(x => x.Request.PersonId).NotEqual(Guid.Empty);
RuleFor(x => x.Request.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Request.BlobPath).NotEmpty().MaximumLength(500);
// Allow either inline content or external reference
RuleFor(x => x.Request)
.Must(r => !string.IsNullOrWhiteSpace(r.ContentBase64) || !string.IsNullOrWhiteSpace(r.BlobPath))
.WithMessage("Either ContentBase64 or BlobPath must be provided");
When(x => !string.IsNullOrWhiteSpace(x.Request.ContentBase64), () =>
{
RuleFor(x => x.Request.ContentType).NotEmpty().MaximumLength(255);
});
RuleFor(x => x.Request.From).LessThanOrEqualTo(x => x.Request.To);
RuleFor(x => x.Request.Summary).MaximumLength(2000).When(x => !string.IsNullOrWhiteSpace(x.Request.Summary));
}
}
@@ -0,0 +1,52 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.Attests;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Attests.Commands;
public record EmployerIssueAttestCommand(Guid EmployerId, EmployerAttestUploadRequest Request) : IRequest<Guid?>;
public class EmployerIssueAttestCommandHandler(IAppDbContext db) : IRequestHandler<EmployerIssueAttestCommand, Guid?>
{
public async Task<Guid?> Handle(EmployerIssueAttestCommand request, CancellationToken cancellationToken)
{
var r = request.Request;
var employerExists = await db.Employers.AnyAsync(e => e.Id == request.EmployerId, cancellationToken);
if (!employerExists) return null;
var personExists = await db.Persons.AnyAsync(p => p.Id == r.PersonId, cancellationToken);
if (!personExists) return null;
byte[]? content = null;
long? length = null;
if (!string.IsNullOrWhiteSpace(r.ContentBase64))
{
try { content = Convert.FromBase64String(r.ContentBase64); length = content.LongLength; }
catch (FormatException) { return null; }
}
var a = new Attest
{
Id = Guid.NewGuid(),
PersonId = r.PersonId,
EmployerId = request.EmployerId,
Title = r.Title,
From = r.From,
To = r.To,
Summary = r.Summary,
BlobPath = r.BlobPath,
BlobHash = r.BlobHash,
Content = content,
ContentType = r.ContentType,
ContentLength = length,
IssuedAt = DateTimeOffset.UtcNow
};
db.Attests.Add(a);
await db.SaveChangesAsync(cancellationToken);
return a.Id;
}
}
@@ -0,0 +1,23 @@
using FluentValidation;
namespace MinAttest.Application.Features.Attests.Commands;
public class EmployerIssueAttestCommandValidator : AbstractValidator<EmployerIssueAttestCommand>
{
public EmployerIssueAttestCommandValidator()
{
RuleFor(x => x.EmployerId).NotEqual(Guid.Empty);
RuleFor(x => x.Request.PersonId).NotEqual(Guid.Empty);
RuleFor(x => x.Request.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Request.BlobPath).MaximumLength(500);
RuleFor(x => x.Request.From).LessThanOrEqualTo(x => x.Request.To);
RuleFor(x => x.Request)
.Must(r => !string.IsNullOrWhiteSpace(r.BlobPath) || !string.IsNullOrWhiteSpace(r.ContentBase64))
.WithMessage("Either BlobPath or ContentBase64 must be provided");
When(x => !string.IsNullOrWhiteSpace(x.Request.ContentBase64), () =>
{
RuleFor(x => x.Request.ContentType).NotEmpty().MaximumLength(255);
});
}
}
@@ -0,0 +1,49 @@
using MediatR;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.Attests;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Attests.Commands;
public record PersonUploadAttestCommand(Guid PersonId, PersonAttestUploadRequest Request) : IRequest<Guid?>;
public class PersonUploadAttestCommandHandler(IAppDbContext db) : IRequestHandler<PersonUploadAttestCommand, Guid?>
{
public async Task<Guid?> Handle(PersonUploadAttestCommand request, CancellationToken cancellationToken)
{
var r = request.Request;
var person = await db.Persons.FindAsync([request.PersonId], cancellationToken);
if (person is null) return null;
byte[]? content = null;
long? length = null;
if (!string.IsNullOrWhiteSpace(r.ContentBase64))
{
try { content = Convert.FromBase64String(r.ContentBase64); length = content.LongLength; }
catch (FormatException) { return null; }
}
var a = new Attest
{
Id = Guid.NewGuid(),
PersonId = request.PersonId,
EmployerId = null,
Title = r.Title,
From = r.From,
To = r.To,
Summary = r.Summary,
BlobPath = r.BlobPath,
BlobHash = r.BlobHash,
Content = content,
ContentType = r.ContentType,
ContentLength = length,
IssuedAt = DateTimeOffset.UtcNow
};
db.Attests.Add(a);
await db.SaveChangesAsync(cancellationToken);
return a.Id;
}
}
@@ -0,0 +1,22 @@
using FluentValidation;
namespace MinAttest.Application.Features.Attests.Commands;
public class PersonUploadAttestCommandValidator : AbstractValidator<PersonUploadAttestCommand>
{
public PersonUploadAttestCommandValidator()
{
RuleFor(x => x.PersonId).NotEqual(Guid.Empty);
RuleFor(x => x.Request.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Request.BlobPath).MaximumLength(500);
RuleFor(x => x.Request.From).LessThanOrEqualTo(x => x.Request.To);
RuleFor(x => x.Request)
.Must(r => !string.IsNullOrWhiteSpace(r.BlobPath) || !string.IsNullOrWhiteSpace(r.ContentBase64))
.WithMessage("Either BlobPath or ContentBase64 must be provided");
When(x => !string.IsNullOrWhiteSpace(x.Request.ContentBase64), () =>
{
RuleFor(x => x.Request.ContentType).NotEmpty().MaximumLength(255);
});
}
}
@@ -0,0 +1,29 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Attests;
using MinAttest.Application.Abstractions;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Attests.Queries;
public record GetAttestByIdQuery(Guid Id) : IRequest<AttestDetails?>;
public class GetAttestByIdQueryHandler(IAppDbContext db) : IRequestHandler<GetAttestByIdQuery, AttestDetails?>
{
public async Task<AttestDetails?> Handle(GetAttestByIdQuery request, CancellationToken cancellationToken)
{
var a = await db.Attests.AsNoTracking().Include(x => x.Employer)
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (a is null) return null;
return new AttestDetails(
a.Id,
a.Employer?.Name ?? string.Empty,
a.Title,
a.From,
a.To,
a.Summary ?? string.Empty,
a.Status == AttestStatus.Issued
);
}
}
@@ -0,0 +1,28 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.Attests.Queries;
public record GetAttestContentQuery(Guid AttestId) : IRequest<AttestContentResult?>;
public record AttestContentResult(byte[] Content, string? ContentType, string FileName);
public class GetAttestContentQueryHandler(IAppDbContext db) : IRequestHandler<GetAttestContentQuery, AttestContentResult?>
{
public async Task<AttestContentResult?> Handle(GetAttestContentQuery request, CancellationToken cancellationToken)
{
var result = await db.Attests.AsNoTracking()
.Where(a => a.Id == request.AttestId)
.Select(a => new { a.Content, a.ContentType, a.Title })
.FirstOrDefaultAsync(cancellationToken);
if (result is null || result.Content is null)
return null;
var name = string.IsNullOrWhiteSpace(result.Title) ? "attest" : result.Title;
// crude filename, no extension without content type mapping
return new AttestContentResult(result.Content, result.ContentType, name);
}
}
@@ -0,0 +1,37 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Attests;
using MinAttest.Domain.Entities;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.Attests.Queries;
public record GetAttestsQuery(Guid? PersonId, Guid? EmployerId, int? Take) : IRequest<IReadOnlyList<AttestSummary>>;
public class GetAttestsQueryHandler(IAppDbContext db) : IRequestHandler<GetAttestsQuery, IReadOnlyList<AttestSummary>>
{
public async Task<IReadOnlyList<AttestSummary>> Handle(GetAttestsQuery request, CancellationToken cancellationToken)
{
var query = db.Attests.AsNoTracking().Include(a => a.Employer).AsQueryable();
if (request.PersonId is Guid pid && pid != Guid.Empty)
query = query.Where(a => a.PersonId == pid);
if (request.EmployerId is Guid eid && eid != Guid.Empty)
query = query.Where(a => a.EmployerId == eid);
var items = await query
.OrderByDescending(a => a.IssuedAt)
.Take(Math.Clamp(request.Take ?? 50, 1, 200))
.Select(a => new AttestSummary(
a.Id,
a.Employer != null ? a.Employer.Name : string.Empty,
a.Title,
a.From,
a.To,
a.Status == AttestStatus.Issued
))
.ToListAsync(cancellationToken);
return items;
}
}
@@ -0,0 +1,22 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.Attests;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Attests.Queries;
public record ListEmployerAttestsQuery(Guid EmployerId, int? Take) : IRequest<IReadOnlyList<AttestSummary>>;
public class ListEmployerAttestsQueryHandler(IAppDbContext db) : IRequestHandler<ListEmployerAttestsQuery, IReadOnlyList<AttestSummary>>
{
public async Task<IReadOnlyList<AttestSummary>> Handle(ListEmployerAttestsQuery request, CancellationToken cancellationToken)
{
return await db.Attests.AsNoTracking().Include(a => a.Employer)
.Where(a => a.EmployerId == request.EmployerId)
.OrderByDescending(a => a.IssuedAt)
.Take(Math.Clamp(request.Take ?? 50, 1, 200))
.Select(a => new AttestSummary(a.Id, (a.Employer != null ? a.Employer.Name : string.Empty), a.Title, a.From, a.To, a.Status == AttestStatus.Issued))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,22 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.Attests;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Attests.Queries;
public record ListPersonAttestsQuery(Guid PersonId, int? Take) : IRequest<IReadOnlyList<AttestSummary>>;
public class ListPersonAttestsQueryHandler(IAppDbContext db) : IRequestHandler<ListPersonAttestsQuery, IReadOnlyList<AttestSummary>>
{
public async Task<IReadOnlyList<AttestSummary>> Handle(ListPersonAttestsQuery request, CancellationToken cancellationToken)
{
return await db.Attests.AsNoTracking().Include(a => a.Employer)
.Where(a => a.PersonId == request.PersonId)
.OrderByDescending(a => a.IssuedAt)
.Take(Math.Clamp(request.Take ?? 50, 1, 200))
.Select(a => new AttestSummary(a.Id, (a.Employer != null ? a.Employer.Name : string.Empty), a.Title, a.From, a.To, a.Status == AttestStatus.Issued))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,20 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.Employers.Commands;
public record DeleteEmployerUserCommand(Guid EmployerId, Guid UserId) : IRequest<bool>;
public class DeleteEmployerUserCommandHandler(IAppDbContext db) : IRequestHandler<DeleteEmployerUserCommand, bool>
{
public async Task<bool> Handle(DeleteEmployerUserCommand request, CancellationToken cancellationToken)
{
var user = await db.EmployerUsers.FirstOrDefaultAsync(u => u.Id == request.UserId && u.EmployerId == request.EmployerId, cancellationToken);
if (user is null) return false;
db.EmployerUsers.Remove(user);
await db.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -0,0 +1,28 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Employers;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.Employers.Commands;
public record UpsertEmployerCommand(string OrgNumber, string Name) : IRequest<EmployerResponse>;
public class UpsertEmployerCommandHandler(IAppDbContext db) : IRequestHandler<UpsertEmployerCommand, EmployerResponse>
{
public async Task<EmployerResponse> Handle(UpsertEmployerCommand request, CancellationToken cancellationToken)
{
var existing = await db.Employers.FirstOrDefaultAsync(e => e.OrgNumber == request.OrgNumber, cancellationToken);
if (existing is null)
{
existing = new MinAttest.Domain.Entities.Employer { Id = Guid.NewGuid(), OrgNumber = request.OrgNumber, Name = request.Name };
db.Employers.Add(existing);
}
else
{
existing.Name = request.Name;
}
await db.SaveChangesAsync(cancellationToken);
return new EmployerResponse(existing.Id, existing.OrgNumber, existing.Name);
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace MinAttest.Application.Features.Employers.Commands;
public class UpsertEmployerCommandValidator : AbstractValidator<UpsertEmployerCommand>
{
public UpsertEmployerCommandValidator()
{
RuleFor(x => x.OrgNumber).NotEmpty().MaximumLength(32);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}
@@ -0,0 +1,55 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.Employers;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Employers.Commands;
public record UpsertEmployerUserCommand(
Guid EmployerId,
string ExternalObjectId,
string Email,
string? Name,
string Role
) : IRequest<EmployerUserResponse?>;
public class UpsertEmployerUserCommandHandler(IAppDbContext db)
: IRequestHandler<UpsertEmployerUserCommand, EmployerUserResponse?>
{
public async Task<EmployerUserResponse?> Handle(UpsertEmployerUserCommand request, CancellationToken cancellationToken)
{
var employer = await db.Employers.FirstOrDefaultAsync(e => e.Id == request.EmployerId, cancellationToken);
if (employer is null) return null;
var roleParsed = Enum.TryParse<EmployerUserRole>(request.Role, ignoreCase: true, out var role)
? role : EmployerUserRole.Issuer;
var user = await db.EmployerUsers.FirstOrDefaultAsync(u => u.ExternalObjectId == request.ExternalObjectId, cancellationToken);
if (user is null)
{
user = new EmployerUser
{
Id = Guid.NewGuid(),
EmployerId = employer.Id,
ExternalObjectId = request.ExternalObjectId,
Email = request.Email,
Name = request.Name,
Role = roleParsed
};
db.EmployerUsers.Add(user);
}
else
{
user.EmployerId = employer.Id;
user.Email = request.Email;
user.Name = request.Name;
user.Role = roleParsed;
}
await db.SaveChangesAsync(cancellationToken);
return new EmployerUserResponse(user.Id, user.EmployerId, user.ExternalObjectId, user.Email, user.Name, user.Role.ToString());
}
}
@@ -0,0 +1,16 @@
using FluentValidation;
namespace MinAttest.Application.Features.Employers.Commands;
public class UpsertEmployerUserCommandValidator : AbstractValidator<UpsertEmployerUserCommand>
{
public UpsertEmployerUserCommandValidator()
{
RuleFor(x => x.EmployerId).NotEqual(Guid.Empty);
RuleFor(x => x.ExternalObjectId).NotEmpty().MaximumLength(128);
RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress();
RuleFor(x => x.Name).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.Name));
RuleFor(x => x.Role).NotEmpty();
}
}
@@ -0,0 +1,18 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Employers;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.Employers.Queries;
public record GetEmployerQuery(Guid Id) : IRequest<EmployerResponse?>;
public class GetEmployerQueryHandler(IAppDbContext db) : IRequestHandler<GetEmployerQuery, EmployerResponse?>
{
public async Task<EmployerResponse?> Handle(GetEmployerQuery request, CancellationToken cancellationToken)
{
var e = await db.Employers.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
if (e is null) return null;
return new EmployerResponse(e.Id, e.OrgNumber, e.Name);
}
}
@@ -0,0 +1,19 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.Employers;
namespace MinAttest.Application.Features.Employers.Queries;
public record GetEmployerUserQuery(Guid EmployerId, Guid UserId) : IRequest<EmployerUserResponse?>;
public class GetEmployerUserQueryHandler(IAppDbContext db) : IRequestHandler<GetEmployerUserQuery, EmployerUserResponse?>
{
public async Task<EmployerUserResponse?> Handle(GetEmployerUserQuery request, CancellationToken cancellationToken)
{
var u = await db.EmployerUsers.FirstOrDefaultAsync(x => x.Id == request.UserId && x.EmployerId == request.EmployerId, cancellationToken);
if (u is null) return null;
return new EmployerUserResponse(u.Id, u.EmployerId, u.ExternalObjectId, u.Email, u.Name, u.Role.ToString());
}
}
@@ -0,0 +1,21 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.Employers;
namespace MinAttest.Application.Features.Employers.Queries;
public record ListEmployerUsersQuery(Guid EmployerId) : IRequest<IReadOnlyList<EmployerUserResponse>>;
public class ListEmployerUsersQueryHandler(IAppDbContext db) : IRequestHandler<ListEmployerUsersQuery, IReadOnlyList<EmployerUserResponse>>
{
public async Task<IReadOnlyList<EmployerUserResponse>> Handle(ListEmployerUsersQuery request, CancellationToken cancellationToken)
{
return await db.EmployerUsers
.Where(u => u.EmployerId == request.EmployerId)
.OrderBy(u => u.Email)
.Select(u => new EmployerUserResponse(u.Id, u.EmployerId, u.ExternalObjectId, u.Email, u.Name, u.Role.ToString()))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,19 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Employers;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.Employers.Queries;
public record ListEmployersQuery() : IRequest<IReadOnlyList<EmployerResponse>>;
public class ListEmployersQueryHandler(IAppDbContext db) : IRequestHandler<ListEmployersQuery, IReadOnlyList<EmployerResponse>>
{
public async Task<IReadOnlyList<EmployerResponse>> Handle(ListEmployersQuery request, CancellationToken cancellationToken)
{
return await db.Employers
.OrderBy(e => e.Name)
.Select(e => new EmployerResponse(e.Id, e.OrgNumber, e.Name))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,36 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Persons;
using MinAttest.Application.Abstractions;
using MinAttest.Domain.Entities;
namespace MinAttest.Application.Features.Persons.Commands;
public record UpsertPersonCommand(string NationalIdHash, string? Email, string? Phone) : IRequest<PersonResponse>;
public class UpsertPersonCommandHandler(IAppDbContext db) : IRequestHandler<UpsertPersonCommand, PersonResponse>
{
public async Task<PersonResponse> Handle(UpsertPersonCommand request, CancellationToken cancellationToken)
{
var p = await db.Persons.FirstOrDefaultAsync(x => x.NationalIdHash == request.NationalIdHash, cancellationToken);
if (p is null)
{
p = new Person
{
Id = Guid.NewGuid(),
NationalIdHash = request.NationalIdHash,
Email = request.Email,
Phone = request.Phone
};
db.Persons.Add(p);
}
else
{
p.Email = request.Email;
p.Phone = request.Phone;
}
await db.SaveChangesAsync(cancellationToken);
return new PersonResponse(p.Id, p.NationalIdHash, p.Email, p.Phone, []);
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace MinAttest.Application.Features.Persons.Commands;
public class UpsertPersonCommandValidator : AbstractValidator<UpsertPersonCommand>
{
public UpsertPersonCommandValidator()
{
RuleFor(x => x.NationalIdHash).NotEmpty().MaximumLength(200);
RuleFor(x => x.Email).MaximumLength(256).When(x => !string.IsNullOrWhiteSpace(x.Email));
RuleFor(x => x.Phone).MaximumLength(50).When(x => !string.IsNullOrWhiteSpace(x.Phone));
}
}
@@ -0,0 +1,37 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Contracts.Attests;
using MinAttest.Contracts.Persons;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.Persons.Queries;
public record GetPersonQuery(Guid Id) : IRequest<PersonResponse?>;
public class GetPersonQueryHandler(IAppDbContext db) : IRequestHandler<GetPersonQuery, PersonResponse?>
{
public async Task<PersonResponse?> Handle(GetPersonQuery request, CancellationToken cancellationToken)
{
var person = await db.Persons
.Where(p => p.Id == request.Id)
.Select(p => new PersonResponse(
p.Id,
p.NationalIdHash,
p.Email,
p.Phone,
p.Attests
.Select(a => new AttestSummary(
a.Id,
a.Employer != null ? a.Employer.Name : string.Empty,
a.Title,
a.From,
a.To,
a.Status == Domain.Entities.AttestStatus.Issued
))
.ToList()
))
.FirstOrDefaultAsync(cancellationToken);
return person;
}
}
@@ -0,0 +1,48 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.ShareLinks;
using System.Security.Cryptography;
namespace MinAttest.Application.Features.ShareLinks.Commands;
public record CreateShareLinkCommand(Guid AttestId, CreateShareLinkRequest Request, string BaseUrl) : IRequest<ShareLinkResponse?>;
public class CreateShareLinkCommandHandler(IAppDbContext db) : IRequestHandler<CreateShareLinkCommand, ShareLinkResponse?>
{
public async Task<ShareLinkResponse?> Handle(CreateShareLinkCommand request, CancellationToken cancellationToken)
{
var attestExists = await db.Attests.AnyAsync(a => a.Id == request.AttestId, cancellationToken);
if (!attestExists) return null;
var code = GenerateCode(16);
var expiresAt = DateTimeOffset.UtcNow.Add(request.Request.ExpiresIn ?? TimeSpan.FromDays(7));
var oneTime = request.Request.OneTime ?? false;
var entity = new MinAttest.Domain.Entities.ShareLink
{
Id = Guid.NewGuid(),
AttestId = request.AttestId,
Code = code,
ExpiresAt = expiresAt,
OneTime = oneTime,
RevokedAt = null
};
db.ShareLinks.Add(entity);
await db.SaveChangesAsync(cancellationToken);
var url = new Uri($"{request.BaseUrl}/api/v1/verify/{code}");
return new ShareLinkResponse(entity.Id, code, url, expiresAt, oneTime);
}
private static string GenerateCode(int bytes)
{
Span<byte> buffer = stackalloc byte[bytes];
RandomNumberGenerator.Fill(buffer);
var b64 = Convert.ToBase64String(buffer);
return b64.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
@@ -0,0 +1,21 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
namespace MinAttest.Application.Features.ShareLinks.Commands;
public record RevokeShareLinkCommand(Guid AttestId, Guid ShareLinkId) : IRequest<bool>;
public class RevokeShareLinkCommandHandler(IAppDbContext db) : IRequestHandler<RevokeShareLinkCommand, bool>
{
public async Task<bool> Handle(RevokeShareLinkCommand request, CancellationToken cancellationToken)
{
var link = await db.ShareLinks.FirstOrDefaultAsync(l => l.Id == request.ShareLinkId && l.AttestId == request.AttestId, cancellationToken);
if (link is null) return false;
if (link.RevokedAt is not null) return true; // already revoked
link.RevokedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -0,0 +1,20 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MinAttest.Application.Abstractions;
using MinAttest.Contracts.ShareLinks;
namespace MinAttest.Application.Features.ShareLinks.Queries;
public record ListShareLinksQuery(Guid AttestId) : IRequest<IReadOnlyList<ShareLinkResponse>>;
public class ListShareLinksQueryHandler(IAppDbContext db) : IRequestHandler<ListShareLinksQuery, IReadOnlyList<ShareLinkResponse>>
{
public async Task<IReadOnlyList<ShareLinkResponse>> Handle(ListShareLinksQuery request, CancellationToken cancellationToken)
{
return await db.ShareLinks.AsNoTracking()
.Where(l => l.AttestId == request.AttestId && l.RevokedAt == null)
.OrderBy(l => l.ExpiresAt)
.Select(l => new ShareLinkResponse(l.Id, l.Code, new Uri($"https://app.example/verify/{l.Code}"), l.ExpiresAt, l.OneTime))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.*" />
<PackageReference Include="FluentValidation" Version="12.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MinAttest.Domain\MinAttest.Domain.csproj" />
<ProjectReference Include="..\MinAttest.Contracts\MinAttest.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,33 @@
namespace MinAttest.Contracts.Attests;
public record AttestSummary(
Guid Id,
string Employer,
string Title,
DateOnly From,
DateOnly To,
bool Verified
);
public record AttestDetails(
Guid Id,
string Employer,
string Title,
DateOnly From,
DateOnly To,
string Summary,
bool Verified
);
public record CompleteUploadRequest(
Guid PersonId,
Guid? EmployerId,
string Title,
DateOnly From,
DateOnly To,
string? Summary,
string BlobPath,
string? BlobHash,
string? ContentBase64,
string? ContentType
);
@@ -0,0 +1,14 @@
namespace MinAttest.Contracts.Attests;
public record EmployerAttestUploadRequest(
Guid PersonId,
string Title,
DateOnly From,
DateOnly To,
string? Summary,
string BlobPath,
string? BlobHash,
string? ContentBase64,
string? ContentType
);
@@ -0,0 +1,13 @@
namespace MinAttest.Contracts.Attests;
public record PersonAttestUploadRequest(
string Title,
DateOnly From,
DateOnly To,
string? Summary,
string BlobPath,
string? BlobHash,
string? ContentBase64,
string? ContentType
);
@@ -0,0 +1,18 @@
namespace MinAttest.Contracts.Employers;
public record EmployerUserUpsertRequest(
string ExternalObjectId,
string Email,
string? Name,
string Role
);
public record EmployerUserResponse(
Guid Id,
Guid EmployerId,
string ExternalObjectId,
string Email,
string? Name,
string Role
);
@@ -0,0 +1,12 @@
namespace MinAttest.Contracts.Employers;
public record EmployerUpsertRequest(
string OrgNumber,
string Name
);
public record EmployerResponse(
Guid Id,
string OrgNumber,
string Name
);
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,17 @@
namespace MinAttest.Contracts.Persons;
using MinAttest.Contracts.Attests;
public record PersonUpsertRequest(
string NationalIdHash,
string? Email,
string? Phone
);
public record PersonResponse(
Guid Id,
string NationalIdHash,
string? Email,
string? Phone,
IReadOnlyList<AttestSummary> Attests
);
@@ -0,0 +1,5 @@
namespace MinAttest.Contracts.ShareLinks;
public record CreateShareLinkRequest(TimeSpan? ExpiresIn, bool? OneTime);
public record ShareLinkResponse(Guid ShareId, string Code, Uri Url, DateTimeOffset ExpiresAt, bool OneTime);
@@ -0,0 +1,30 @@
namespace MinAttest.Domain.Entities;
public class Attest
{
public Guid Id { get; set; }
public Guid PersonId { get; set; }
public Person Person { get; set; } = null!;
public Guid? EmployerId { get; set; }
public Employer? Employer { get; set; }
public string Title { get; set; } = string.Empty;
public DateOnly From { get; set; }
public DateOnly To { get; set; }
public string? Summary { get; set; }
public string BlobPath { get; set; } = string.Empty;
public string? BlobHash { get; set; }
// Temporary in-DB storage (to be replaced by Azure Blob)
public byte[]? Content { get; set; }
public string? ContentType { get; set; }
public long? ContentLength { get; set; }
public AttestStatus Status { get; set; } = AttestStatus.Unverified;
public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow;
public string? IssuedBy { get; set; }
public ICollection<ShareLink> ShareLinks { get; set; } = [];
}
@@ -0,0 +1,13 @@
namespace MinAttest.Domain.Entities;
public class AuditLog
{
public Guid Id { get; set; }
public ActorType ActorType { get; set; }
public Guid? ActorId { get; set; }
public string Action { get; set; } = string.Empty;
public string TargetType { get; set; } = string.Empty;
public Guid TargetId { get; set; }
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
public string? Ip { get; set; }
}
@@ -0,0 +1,11 @@
namespace MinAttest.Domain.Entities;
public class Employer
{
public Guid Id { get; set; }
public string OrgNumber { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public ICollection<Attest> Attests { get; set; } = [];
public ICollection<EmployerUser> Users { get; set; } = [];
}
@@ -0,0 +1,17 @@
namespace MinAttest.Domain.Entities;
public class EmployerUser
{
public Guid Id { get; set; }
public Guid EmployerId { get; set; }
public Employer Employer { get; set; } = null!;
// Entra ID object id of the user
public string ExternalObjectId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string? Name { get; set; }
public EmployerUserRole Role { get; set; } = EmployerUserRole.Issuer;
}
@@ -0,0 +1,21 @@
namespace MinAttest.Domain.Entities;
public enum AttestStatus
{
Issued = 1,
Unverified = 2,
Revoked = 3
}
public enum ActorType
{
Person = 1,
Employer = 2,
Verifier = 3
}
public enum EmployerUserRole
{
Issuer = 1,
Admin = 2
}
@@ -0,0 +1,12 @@
namespace MinAttest.Domain.Entities;
public class Person
{
public Guid Id { get; set; }
public string NationalIdHash { get; set; } = null!;
public string? NationalIdEncrypted { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public ICollection<Attest> Attests { get; set; } = [];
}
@@ -0,0 +1,13 @@
namespace MinAttest.Domain.Entities;
public class ShareLink
{
public Guid Id { get; set; }
public Guid AttestId { get; set; }
public Attest Attest { get; set; } = null!;
public string Code { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public bool OneTime { get; set; }
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using MinAttest.Domain.Entities;
using MinAttest.Application.Abstractions;
namespace MinAttest.Infrastructure.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options), IAppDbContext
{
public DbSet<Person> Persons => Set<Person>();
public DbSet<Employer> Employers => Set<Employer>();
public DbSet<Attest> Attests => Set<Attest>();
public DbSet<ShareLink> ShareLinks => Set<ShareLink>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<EmployerUser> EmployerUsers => Set<EmployerUser>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Person>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.NationalIdHash).HasMaxLength(200);
b.HasIndex(x => x.NationalIdHash);
b.ToTable(t => t.IsTemporal(tt => tt
.UseHistoryTable("PersonsHistory")
));
});
modelBuilder.Entity<Employer>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.OrgNumber).HasMaxLength(32);
b.HasIndex(x => x.OrgNumber).IsUnique(false);
b.ToTable(t => t.IsTemporal(tt => tt
.UseHistoryTable("EmployersHistory")
));
});
modelBuilder.Entity<Attest>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.Title).HasMaxLength(200);
b.Property(x => x.Summary).HasMaxLength(2000);
b.Property(x => x.BlobPath).HasMaxLength(500);
b.Property(x => x.BlobHash).HasMaxLength(128);
b.Property(x => x.Content).HasColumnType("varbinary(max)");
b.Property(x => x.ContentType).HasMaxLength(255);
b.Property(x => x.ContentLength).HasColumnType("bigint");
b.HasOne(x => x.Person)
.WithMany(x => x.Attests)
.HasForeignKey(x => x.PersonId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Employer)
.WithMany(x => x.Attests)
.HasForeignKey(x => x.EmployerId)
.OnDelete(DeleteBehavior.Restrict);
// Consistency: EmployerId null <=> Unverified, EmployerId set => Issued/Revoked
b.ToTable(t =>
{
t.HasCheckConstraint(
"CK_Attests_Verification",
"(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))");
t.IsTemporal(tt => tt.UseHistoryTable("AttestsHistory"));
});
// Helpful composite indexes for common queries
b.HasIndex(x => new { x.PersonId, x.Status });
b.HasIndex(x => new { x.EmployerId, x.Status });
});
modelBuilder.Entity<ShareLink>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.Code).HasMaxLength(200);
b.HasIndex(x => x.Code).IsUnique();
b.HasOne(x => x.Attest)
.WithMany(x => x.ShareLinks)
.HasForeignKey(x => x.AttestId)
.OnDelete(DeleteBehavior.Cascade);
b.ToTable(t => t.IsTemporal(tt => tt
.UseHistoryTable("ShareLinksHistory")
));
});
modelBuilder.Entity<EmployerUser>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.ExternalObjectId).HasMaxLength(128);
b.Property(x => x.Email).HasMaxLength(256);
b.Property(x => x.Name).HasMaxLength(200);
b.HasIndex(x => x.ExternalObjectId).IsUnique();
b.HasIndex(x => x.EmployerId);
b.HasOne(x => x.Employer)
.WithMany(e => e.Users)
.HasForeignKey(x => x.EmployerId)
.OnDelete(DeleteBehavior.Cascade);
b.ToTable(t => t.IsTemporal(tt => tt
.UseHistoryTable("EmployerUsersHistory")
));
});
modelBuilder.Entity<AuditLog>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.Action).HasMaxLength(100);
b.Property(x => x.TargetType).HasMaxLength(100);
b.Property(x => x.Ip).HasMaxLength(64);
b.HasIndex(x => x.Timestamp);
});
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
NormalizeAttestStatuses();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
NormalizeAttestStatuses();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void NormalizeAttestStatuses()
{
foreach (var entry in ChangeTracker.Entries<Attest>())
{
if (entry.State is not (EntityState.Added or EntityState.Modified)) continue;
var a = entry.Entity;
if (a.EmployerId is null)
{
// No employer -> always Unverified
a.Status = AttestStatus.Unverified;
}
else if (a.Status == AttestStatus.Unverified)
{
// Has employer but Unverified -> default to Issued
a.Status = AttestStatus.Issued;
}
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace MinAttest.Infrastructure.Data;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var basePath = Directory.GetCurrentDirectory();
Directory.CreateDirectory(Path.Combine(basePath, "data"));
var config = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var cs = config.GetConnectionString("Default")
?? config.GetConnectionString("MinAttest")
?? "Server=localhost,1433;Database=MinAttest;User Id=sa;Password=Your_password123;TrustServerCertificate=True;";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(cs)
.Options;
return new AppDbContext(options);
}
}
@@ -0,0 +1,249 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MinAttest.Infrastructure.Data;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20250913095523_InitialSqlServer")]
partial class InitialSqlServer
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("BlobPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid?>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<DateOnly>("From")
.HasColumnType("date");
b.Property<DateTimeOffset>("IssuedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("IssuedBy")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("PersonId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly>("To")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EmployerId");
b.HasIndex("PersonId");
b.ToTable("Attests");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uniqueidentifier");
b.Property<int>("ActorType")
.HasColumnType("int");
b.Property<string>("Ip")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("TargetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("OrgNumber")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("OrgNumber");
b.ToTable("Employers");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdEncrypted")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NationalIdHash");
b.ToTable("Persons");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AttestId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("OneTime")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AttestId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ShareLinks");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b =>
{
b.HasOne("MinAttest.Api.Data.Entities.Employer", "Employer")
.WithMany("Attests")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MinAttest.Api.Data.Entities.Person", "Person")
.WithMany("Attests")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
b.Navigation("Person");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b =>
{
b.HasOne("MinAttest.Api.Data.Entities.Attest", "Attest")
.WithMany("ShareLinks")
.HasForeignKey("AttestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Attest");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b =>
{
b.Navigation("ShareLinks");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b =>
{
b.Navigation("Attests");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b =>
{
b.Navigation("Attests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,172 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialSqlServer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ActorType = table.Column<int>(type: "int", nullable: false),
ActorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Action = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
TargetType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
TargetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Timestamp = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
Ip = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Employers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OrgNumber = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Employers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Persons",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
NationalIdHash = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
NationalIdEncrypted = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Persons", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Attests",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PersonId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EmployerId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
From = table.Column<DateOnly>(type: "date", nullable: false),
To = table.Column<DateOnly>(type: "date", nullable: false),
Summary = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
BlobPath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
BlobHash = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
IssuedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
IssuedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Attests", x => x.Id);
table.ForeignKey(
name: "FK_Attests_Employers_EmployerId",
column: x => x.EmployerId,
principalTable: "Employers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Attests_Persons_PersonId",
column: x => x.PersonId,
principalTable: "Persons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ShareLinks",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AttestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
RevokedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
OneTime = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ShareLinks", x => x.Id);
table.ForeignKey(
name: "FK_ShareLinks_Attests_AttestId",
column: x => x.AttestId,
principalTable: "Attests",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Attests_EmployerId",
table: "Attests",
column: "EmployerId");
migrationBuilder.CreateIndex(
name: "IX_Attests_PersonId",
table: "Attests",
column: "PersonId");
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_Timestamp",
table: "AuditLogs",
column: "Timestamp");
migrationBuilder.CreateIndex(
name: "IX_Employers_OrgNumber",
table: "Employers",
column: "OrgNumber");
migrationBuilder.CreateIndex(
name: "IX_Persons_NationalIdHash",
table: "Persons",
column: "NationalIdHash");
migrationBuilder.CreateIndex(
name: "IX_ShareLinks_AttestId",
table: "ShareLinks",
column: "AttestId");
migrationBuilder.CreateIndex(
name: "IX_ShareLinks_Code",
table: "ShareLinks",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
migrationBuilder.DropTable(
name: "ShareLinks");
migrationBuilder.DropTable(
name: "Attests");
migrationBuilder.DropTable(
name: "Employers");
migrationBuilder.DropTable(
name: "Persons");
}
}
}
@@ -0,0 +1,252 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MinAttest.Infrastructure.Data;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20250913104919_AddAttestVerificationCheck")]
partial class AddAttestVerificationCheck
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("BlobPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid?>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<DateOnly>("From")
.HasColumnType("date");
b.Property<DateTimeOffset>("IssuedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("IssuedBy")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("PersonId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly>("To")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EmployerId", "Status");
b.HasIndex("PersonId", "Status");
b.ToTable("Attests", t =>
{
t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))");
});
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uniqueidentifier");
b.Property<int>("ActorType")
.HasColumnType("int");
b.Property<string>("Ip")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("TargetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("OrgNumber")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("OrgNumber");
b.ToTable("Employers");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdEncrypted")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NationalIdHash");
b.ToTable("Persons");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AttestId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("OneTime")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AttestId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ShareLinks");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b =>
{
b.HasOne("MinAttest.Api.Data.Entities.Employer", "Employer")
.WithMany("Attests")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MinAttest.Api.Data.Entities.Person", "Person")
.WithMany("Attests")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
b.Navigation("Person");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b =>
{
b.HasOne("MinAttest.Api.Data.Entities.Attest", "Attest")
.WithMany("ShareLinks")
.HasForeignKey("AttestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Attest");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b =>
{
b.Navigation("ShareLinks");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b =>
{
b.Navigation("Attests");
});
modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b =>
{
b.Navigation("Attests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAttestVerificationCheck : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Attests_EmployerId",
table: "Attests");
migrationBuilder.DropIndex(
name: "IX_Attests_PersonId",
table: "Attests");
migrationBuilder.CreateIndex(
name: "IX_Attests_EmployerId_Status",
table: "Attests",
columns: new[] { "EmployerId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Attests_PersonId_Status",
table: "Attests",
columns: new[] { "PersonId", "Status" });
migrationBuilder.AddCheckConstraint(
name: "CK_Attests_Verification",
table: "Attests",
sql: "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Attests_EmployerId_Status",
table: "Attests");
migrationBuilder.DropIndex(
name: "IX_Attests_PersonId_Status",
table: "Attests");
migrationBuilder.DropCheckConstraint(
name: "CK_Attests_Verification",
table: "Attests");
migrationBuilder.CreateIndex(
name: "IX_Attests_EmployerId",
table: "Attests",
column: "EmployerId");
migrationBuilder.CreateIndex(
name: "IX_Attests_PersonId",
table: "Attests",
column: "PersonId");
}
}
}
@@ -0,0 +1,336 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MinAttest.Infrastructure.Data;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20250913112309_EnableTemporalTables")]
partial class EnableTemporalTables
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("BlobPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid?>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<DateOnly>("From")
.HasColumnType("date");
b.Property<DateTimeOffset>("IssuedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("IssuedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<Guid>("PersonId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly>("To")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EmployerId", "Status");
b.HasIndex("PersonId", "Status");
b.ToTable("Attests", t =>
{
t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))");
});
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("AttestsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uniqueidentifier");
b.Property<int>("ActorType")
.HasColumnType("int");
b.Property<string>("Ip")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("TargetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("OrgNumber")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.HasKey("Id");
b.HasIndex("OrgNumber");
b.ToTable("Employers");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("EmployersHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdEncrypted")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NationalIdHash");
b.ToTable("Persons");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("PersonsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AttestId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("OneTime")
.HasColumnType("bit");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AttestId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ShareLinks");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("ShareLinksHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.HasOne("MinAttest.Domain.Entities.Employer", "Employer")
.WithMany("Attests")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MinAttest.Domain.Entities.Person", "Person")
.WithMany("Attests")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
b.Navigation("Person");
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.HasOne("MinAttest.Domain.Entities.Attest", "Attest")
.WithMany("ShareLinks")
.HasForeignKey("AttestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Attest");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Navigation("ShareLinks");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Navigation("Attests");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Navigation("Attests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,187 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class EnableTemporalTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterTable(
name: "ShareLinks")
.Annotation("SqlServer:IsTemporal", true)
.Annotation("SqlServer:TemporalHistoryTableName", "ShareLinksHistory")
.Annotation("SqlServer:TemporalHistoryTableSchema", null)
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.AlterTable(
name: "Persons")
.Annotation("SqlServer:IsTemporal", true)
.Annotation("SqlServer:TemporalHistoryTableName", "PersonsHistory")
.Annotation("SqlServer:TemporalHistoryTableSchema", null)
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.AlterTable(
name: "Employers")
.Annotation("SqlServer:IsTemporal", true)
.Annotation("SqlServer:TemporalHistoryTableName", "EmployersHistory")
.Annotation("SqlServer:TemporalHistoryTableSchema", null)
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.AlterTable(
name: "Attests")
.Annotation("SqlServer:IsTemporal", true)
.Annotation("SqlServer:TemporalHistoryTableName", "AttestsHistory")
.Annotation("SqlServer:TemporalHistoryTableSchema", null)
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.AddColumn<DateTime>(
name: "PeriodEnd",
table: "ShareLinks",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.AddColumn<DateTime>(
name: "PeriodStart",
table: "ShareLinks",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
migrationBuilder.AddColumn<DateTime>(
name: "PeriodEnd",
table: "Persons",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.AddColumn<DateTime>(
name: "PeriodStart",
table: "Persons",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
migrationBuilder.AddColumn<DateTime>(
name: "PeriodEnd",
table: "Employers",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.AddColumn<DateTime>(
name: "PeriodStart",
table: "Employers",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
migrationBuilder.AddColumn<DateTime>(
name: "PeriodEnd",
table: "Attests",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.AddColumn<DateTime>(
name: "PeriodStart",
table: "Attests",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PeriodEnd",
table: "ShareLinks")
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.DropColumn(
name: "PeriodStart",
table: "ShareLinks")
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
migrationBuilder.DropColumn(
name: "PeriodEnd",
table: "Persons")
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.DropColumn(
name: "PeriodStart",
table: "Persons")
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
migrationBuilder.DropColumn(
name: "PeriodEnd",
table: "Employers")
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.DropColumn(
name: "PeriodStart",
table: "Employers")
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
migrationBuilder.DropColumn(
name: "PeriodEnd",
table: "Attests")
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true);
migrationBuilder.DropColumn(
name: "PeriodStart",
table: "Attests")
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
migrationBuilder.AlterTable(
name: "ShareLinks")
.OldAnnotation("SqlServer:IsTemporal", true)
.OldAnnotation("SqlServer:TemporalHistoryTableName", "ShareLinksHistory")
.OldAnnotation("SqlServer:TemporalHistoryTableSchema", null)
.OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.AlterTable(
name: "Persons")
.OldAnnotation("SqlServer:IsTemporal", true)
.OldAnnotation("SqlServer:TemporalHistoryTableName", "PersonsHistory")
.OldAnnotation("SqlServer:TemporalHistoryTableSchema", null)
.OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.AlterTable(
name: "Employers")
.OldAnnotation("SqlServer:IsTemporal", true)
.OldAnnotation("SqlServer:TemporalHistoryTableName", "EmployersHistory")
.OldAnnotation("SqlServer:TemporalHistoryTableSchema", null)
.OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.AlterTable(
name: "Attests")
.OldAnnotation("SqlServer:IsTemporal", true)
.OldAnnotation("SqlServer:TemporalHistoryTableName", "AttestsHistory")
.OldAnnotation("SqlServer:TemporalHistoryTableSchema", null)
.OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
}
}
}
@@ -0,0 +1,406 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MinAttest.Infrastructure.Data;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20250913122233_AddEmployerUser")]
partial class AddEmployerUser
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("BlobPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid?>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<DateOnly>("From")
.HasColumnType("date");
b.Property<DateTimeOffset>("IssuedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("IssuedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<Guid>("PersonId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly>("To")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EmployerId", "Status");
b.HasIndex("PersonId", "Status");
b.ToTable("Attests", t =>
{
t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))");
});
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("AttestsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uniqueidentifier");
b.Property<int>("ActorType")
.HasColumnType("int");
b.Property<string>("Ip")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("TargetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("OrgNumber")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.HasKey("Id");
b.HasIndex("OrgNumber");
b.ToTable("Employers");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("EmployersHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<Guid>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ExternalObjectId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<int>("Role")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("EmployerId");
b.HasIndex("ExternalObjectId")
.IsUnique();
b.ToTable("EmployerUsers");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("EmployerUsersHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdEncrypted")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NationalIdHash");
b.ToTable("Persons");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("PersonsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AttestId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("OneTime")
.HasColumnType("bit");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AttestId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ShareLinks");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("ShareLinksHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.HasOne("MinAttest.Domain.Entities.Employer", "Employer")
.WithMany("Attests")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MinAttest.Domain.Entities.Person", "Person")
.WithMany("Attests")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
b.Navigation("Person");
});
modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b =>
{
b.HasOne("MinAttest.Domain.Entities.Employer", "Employer")
.WithMany("Users")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.HasOne("MinAttest.Domain.Entities.Attest", "Attest")
.WithMany("ShareLinks")
.HasForeignKey("AttestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Attest");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Navigation("ShareLinks");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Navigation("Attests");
b.Navigation("Users");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Navigation("Attests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddEmployerUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmployerUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EmployerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ExternalObjectId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Role = table.Column<int>(type: "int", nullable: false),
PeriodEnd = table.Column<DateTime>(type: "datetime2", nullable: false)
.Annotation("SqlServer:TemporalIsPeriodEndColumn", true),
PeriodStart = table.Column<DateTime>(type: "datetime2", nullable: false)
.Annotation("SqlServer:TemporalIsPeriodStartColumn", true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmployerUsers", x => x.Id);
table.ForeignKey(
name: "FK_EmployerUsers_Employers_EmployerId",
column: x => x.EmployerId,
principalTable: "Employers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("SqlServer:IsTemporal", true)
.Annotation("SqlServer:TemporalHistoryTableName", "EmployerUsersHistory")
.Annotation("SqlServer:TemporalHistoryTableSchema", null)
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
migrationBuilder.CreateIndex(
name: "IX_EmployerUsers_EmployerId",
table: "EmployerUsers",
column: "EmployerId");
migrationBuilder.CreateIndex(
name: "IX_EmployerUsers_ExternalObjectId",
table: "EmployerUsers",
column: "ExternalObjectId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmployerUsers")
.Annotation("SqlServer:IsTemporal", true)
.Annotation("SqlServer:TemporalHistoryTableName", "EmployerUsersHistory")
.Annotation("SqlServer:TemporalHistoryTableSchema", null)
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
}
}
}
@@ -0,0 +1,416 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MinAttest.Infrastructure.Data;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20250913124425_AddAttestContentColumns")]
partial class AddAttestContentColumns
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("BlobPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<byte[]>("Content")
.HasColumnType("varbinary(max)");
b.Property<long?>("ContentLength")
.HasColumnType("bigint");
b.Property<string>("ContentType")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<Guid?>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<DateOnly>("From")
.HasColumnType("date");
b.Property<DateTimeOffset>("IssuedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("IssuedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<Guid>("PersonId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly>("To")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EmployerId", "Status");
b.HasIndex("PersonId", "Status");
b.ToTable("Attests", t =>
{
t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))");
});
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("AttestsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uniqueidentifier");
b.Property<int>("ActorType")
.HasColumnType("int");
b.Property<string>("Ip")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("TargetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("OrgNumber")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.HasKey("Id");
b.HasIndex("OrgNumber");
b.ToTable("Employers");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("EmployersHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<Guid>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ExternalObjectId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<int>("Role")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("EmployerId");
b.HasIndex("ExternalObjectId")
.IsUnique();
b.ToTable("EmployerUsers");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("EmployerUsersHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdEncrypted")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NationalIdHash");
b.ToTable("Persons");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("PersonsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AttestId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("OneTime")
.HasColumnType("bit");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AttestId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ShareLinks");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("ShareLinksHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.HasOne("MinAttest.Domain.Entities.Employer", "Employer")
.WithMany("Attests")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MinAttest.Domain.Entities.Person", "Person")
.WithMany("Attests")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
b.Navigation("Person");
});
modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b =>
{
b.HasOne("MinAttest.Domain.Entities.Employer", "Employer")
.WithMany("Users")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.HasOne("MinAttest.Domain.Entities.Attest", "Attest")
.WithMany("ShareLinks")
.HasForeignKey("AttestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Attest");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Navigation("ShareLinks");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Navigation("Attests");
b.Navigation("Users");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Navigation("Attests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAttestContentColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "Content",
table: "Attests",
type: "varbinary(max)",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ContentLength",
table: "Attests",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ContentType",
table: "Attests",
type: "nvarchar(255)",
maxLength: 255,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Content",
table: "Attests");
migrationBuilder.DropColumn(
name: "ContentLength",
table: "Attests");
migrationBuilder.DropColumn(
name: "ContentType",
table: "Attests");
}
}
}
@@ -0,0 +1,413 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MinAttest.Infrastructure.Data;
#nullable disable
namespace MinAttest.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("BlobPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<byte[]>("Content")
.HasColumnType("varbinary(max)");
b.Property<long?>("ContentLength")
.HasColumnType("bigint");
b.Property<string>("ContentType")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<Guid?>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<DateOnly>("From")
.HasColumnType("date");
b.Property<DateTimeOffset>("IssuedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("IssuedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<Guid>("PersonId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly>("To")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EmployerId", "Status");
b.HasIndex("PersonId", "Status");
b.ToTable("Attests", t =>
{
t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))");
});
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("AttestsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uniqueidentifier");
b.Property<int>("ActorType")
.HasColumnType("int");
b.Property<string>("Ip")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("TargetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("OrgNumber")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.HasKey("Id");
b.HasIndex("OrgNumber");
b.ToTable("Employers");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("EmployersHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<Guid>("EmployerId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ExternalObjectId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<int>("Role")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("EmployerId");
b.HasIndex("ExternalObjectId")
.IsUnique();
b.ToTable("EmployerUsers");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("EmployerUsersHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdEncrypted")
.HasColumnType("nvarchar(max)");
b.Property<string>("NationalIdHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NationalIdHash");
b.ToTable("Persons");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("PersonsHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AttestId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("OneTime")
.HasColumnType("bit");
b.Property<DateTime>("PeriodEnd")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodEnd");
b.Property<DateTime>("PeriodStart")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2")
.HasColumnName("PeriodStart");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AttestId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ShareLinks");
b.ToTable(tb => tb.IsTemporal(ttb =>
{
ttb.UseHistoryTable("ShareLinksHistory");
ttb
.HasPeriodStart("PeriodStart")
.HasColumnName("PeriodStart");
ttb
.HasPeriodEnd("PeriodEnd")
.HasColumnName("PeriodEnd");
}));
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.HasOne("MinAttest.Domain.Entities.Employer", "Employer")
.WithMany("Attests")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MinAttest.Domain.Entities.Person", "Person")
.WithMany("Attests")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
b.Navigation("Person");
});
modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b =>
{
b.HasOne("MinAttest.Domain.Entities.Employer", "Employer")
.WithMany("Users")
.HasForeignKey("EmployerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employer");
});
modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b =>
{
b.HasOne("MinAttest.Domain.Entities.Attest", "Attest")
.WithMany("ShareLinks")
.HasForeignKey("AttestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Attest");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b =>
{
b.Navigation("ShareLinks");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b =>
{
b.Navigation("Attests");
b.Navigation("Users");
});
modelBuilder.Entity("MinAttest.Domain.Entities.Person", b =>
{
b.Navigation("Attests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MinAttest.Domain\MinAttest.Domain.csproj" />
<ProjectReference Include="..\MinAttest.Application\MinAttest.Application.csproj" />
</ItemGroup>
</Project>