feat: implement strangler-fig-demo Session 1 (backend + smoke script)

Builds the four-seam, three-write-path reference demo backend: case-framework
(seam D stand-in), legacy-backend/frontend (SQL Server, seams A/B/C targets),
and new-backend (Domain/Application/Infrastructure.*/Api implementing the
source resolver, take/release-ownership, write-through translator, and owned
assessment flow), wired together via docker-compose with a plain placeholder
frontend standing in for the Angular portal until Session 2.

All 11 Architecture.Tests pass and scripts/smoke.sh passes end-to-end against
a fresh `docker compose up`, covering acceptance criteria 1-3 and 7-22.

Fixes two real domain bugs found only once the stack ran for real: the BSN
eleven-proof checksum trivially passes all-zero digits, and the adoption
mapper silently treated a partial legacy address as absent instead of failing
loudly. Also fixes several environment-specific integration issues (rootless
Podman/SELinux bind-mount permissions, a buildah NuGet layer-caching bug,
SqlClient's invariant-globalization incompatibility, and an nginx path-prefix
mismatch for the legacy frontend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-31 07:57:26 +02:00
co-authored by Claude Sonnet 5
parent 09b27173a7
commit a6a1abbe9c
129 changed files with 6379 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
namespace Legacy.Api.Data;
/// <summary>
/// Maps to dbo.AANVR. Property names deliberately mirror the legacy column
/// vocabulary (abbreviated Dutch) rather than modern domain terms - that
/// translation is the downstream anti-corruption layer's job, not ours.
/// </summary>
public class Aanvraag
{
public int Id { get; set; }
public string Bsn { get; set; } = "";
public string Naam { get; set; } = "";
public string? Voorl { get; set; }
public string? AdresStr { get; set; }
public string? AdresNr { get; set; }
public string? AdresPc { get; set; }
public string? AdresPl { get; set; }
public string? Email { get; set; }
public string? Telnr { get; set; }
public string CorrKanaal { get; set; } = "P";
public string StatCd { get; set; } = "O";
public string? DiplCd { get; set; }
public string? DiplLand { get; set; }
public DateOnly? DiplDat { get; set; }
public DateOnly DatOntv { get; set; }
public DateOnly? DatBeoord { get; set; }
public string? BeoordRes { get; set; }
public string? BeoordMotiv { get; set; }
public bool Migrated { get; set; }
public DateTime MutDat { get; set; }
public string MutUser { get; set; } = "seed";
}
@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
namespace Legacy.Api.Data;
public class LegacyDbContext(DbContextOptions<LegacyDbContext> options) : DbContext(options)
{
public DbSet<Aanvraag> Aanvragen => Set<Aanvraag>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Aanvraag>(e =>
{
e.ToTable("AANVR", "dbo");
e.HasKey(a => a.Id);
e.Property(a => a.Id).HasColumnName("AANVR_ID").ValueGeneratedOnAdd();
e.Property(a => a.Bsn).HasColumnName("BSN").HasColumnType("char(9)").IsRequired();
e.Property(a => a.Naam).HasColumnName("NAAM").HasMaxLength(60).IsRequired();
e.Property(a => a.Voorl).HasColumnName("VOORL").HasMaxLength(10);
e.Property(a => a.AdresStr).HasColumnName("ADRES_STR").HasMaxLength(80);
e.Property(a => a.AdresNr).HasColumnName("ADRES_NR").HasMaxLength(10);
e.Property(a => a.AdresPc).HasColumnName("ADRES_PC").HasColumnType("char(6)");
e.Property(a => a.AdresPl).HasColumnName("ADRES_PL").HasMaxLength(60);
e.Property(a => a.Email).HasColumnName("EMAIL").HasMaxLength(120);
e.Property(a => a.Telnr).HasColumnName("TELNR").HasMaxLength(20);
e.Property(a => a.CorrKanaal).HasColumnName("CORR_KANAAL").HasColumnType("char(1)")
.HasDefaultValue("P").IsRequired();
e.Property(a => a.StatCd).HasColumnName("STAT_CD").HasColumnType("char(1)").IsRequired();
e.Property(a => a.DiplCd).HasColumnName("DIPL_CD").HasMaxLength(10);
e.Property(a => a.DiplLand).HasColumnName("DIPL_LAND").HasColumnType("char(2)");
e.Property(a => a.DiplDat).HasColumnName("DIPL_DAT").HasColumnType("date");
e.Property(a => a.DatOntv).HasColumnName("DAT_ONTV").HasColumnType("date").IsRequired();
e.Property(a => a.DatBeoord).HasColumnName("DAT_BEOORD").HasColumnType("date");
e.Property(a => a.BeoordRes).HasColumnName("BEOORD_RES").HasColumnType("char(1)");
e.Property(a => a.BeoordMotiv).HasColumnName("BEOORD_MOTIV").HasMaxLength(500);
e.Property(a => a.Migrated).HasColumnName("MIGRATED").HasDefaultValue(false).IsRequired();
e.Property(a => a.MutDat).HasColumnName("MUT_DAT").HasColumnType("datetime2").IsRequired();
e.Property(a => a.MutUser).HasColumnName("MUT_USER").HasMaxLength(30).IsRequired();
});
}
}
+144
View File
@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
namespace Legacy.Api.Data;
/// <summary>
/// Idempotent seed of the 12 demo AANVR rows at ids 1001-1012. Safe to run on
/// every startup: it only inserts when the table is empty.
/// </summary>
public static class LegacySeeder
{
public static async Task SeedAsync(LegacyDbContext db)
{
if (await db.Aanvragen.AnyAsync())
{
return;
}
var rows = new List<Aanvraag>
{
new()
{
Id = 1001, Bsn = "195751814", Naam = "de Vries", Voorl = "A.",
AdresStr = "Kerkweg", AdresNr = "12", AdresPc = "3512JK", AdresPl = "Utrecht",
Email = "anna.devries@example.nl", Telnr = "+31 6 12345678", CorrKanaal = "P",
StatCd = "O", DiplCd = "WO-ECO", DiplLand = "DE", DiplDat = new DateOnly(2015, 6, 20),
DatOntv = new DateOnly(2026, 3, 10),
MutDat = new DateTime(2026, 3, 10, 9, 15, 0), MutUser = "seed",
},
new()
{
Id = 1002, Bsn = "254488808", Naam = "Jansen", Voorl = "P.",
AdresStr = "Prinsengracht", AdresNr = "45", AdresPc = "1016HB", AdresPl = "Amsterdam",
Email = "piet.jansen@example.nl", Telnr = "020 1234567", CorrKanaal = "P",
StatCd = "B", DiplCd = "HBO-VPK", DiplLand = "BE", DiplDat = new DateOnly(2012, 7, 1),
DatOntv = new DateOnly(2025, 11, 20), DatBeoord = new DateOnly(2025, 12, 5),
BeoordRes = "G",
BeoordMotiv = "Aanvraag voldoet aan alle diploma-eisen en documentatie is compleet.",
MutDat = new DateTime(2025, 12, 5, 11, 0, 0), MutUser = "seed",
},
new()
{
Id = 1003, Bsn = "862102455", Naam = "El Amrani", Voorl = "F.",
AdresStr = "Molenstraat", AdresNr = "8", AdresPc = "5611EM", AdresPl = "Eindhoven",
Email = null, Telnr = "+31 6 87654321", CorrKanaal = "E",
StatCd = "O", DiplCd = "WO-ING", DiplLand = "MA", DiplDat = new DateOnly(2018, 6, 15),
DatOntv = new DateOnly(2026, 5, 2),
MutDat = new DateTime(2026, 5, 2, 8, 45, 0), MutUser = "seed",
},
new()
{
Id = 1004, Bsn = "501061964", Naam = "Bakker", Voorl = "L.",
AdresStr = "Nieuwstraat", AdresNr = "22", AdresPc = "4811XB", AdresPl = "Breda",
Email = "lisa.bakker@example.nl", Telnr = "076 5432109", CorrKanaal = "P",
StatCd = "X", DiplCd = "HBO-ICT", DiplLand = "GB", DiplDat = new DateOnly(2010, 5, 10),
DatOntv = new DateOnly(2025, 8, 14),
MutDat = new DateTime(2025, 8, 20, 14, 30, 0), MutUser = "seed",
},
new()
{
Id = 1005, Bsn = "000000000", Naam = "Visser", Voorl = "J.",
AdresStr = "Hoofdstraat", AdresNr = "3", AdresPc = "9711AA", AdresPl = "Groningen",
Email = "jan.visser@example.nl", Telnr = "+31 6 11223344", CorrKanaal = "P",
StatCd = "O", DiplCd = "MBO-ZORG", DiplLand = "PL", DiplDat = new DateOnly(2019, 9, 1),
DatOntv = new DateOnly(2026, 2, 18),
MutDat = new DateTime(2026, 2, 18, 10, 5, 0), MutUser = "seed",
},
new()
{
Id = 1006, Bsn = "184513418", Naam = "Okonkwo", Voorl = "C.",
AdresStr = "Zuidplein", AdresNr = "14", AdresPc = "3083CN", AdresPl = "Rotterdam",
Email = "c.okonkwo@example.nl", Telnr = "+31 6 22334455", CorrKanaal = "P",
StatCd = "B", DiplCd = "WO-GEN", DiplLand = "NG", DiplDat = new DateOnly(2016, 7, 1),
DatOntv = new DateOnly(2025, 10, 1), DatBeoord = new DateOnly(2025, 10, 15),
BeoordRes = "G", BeoordMotiv = "Akkoord",
MutDat = new DateTime(2025, 10, 15, 13, 20, 0), MutUser = "seed",
},
new()
{
Id = 1007, Bsn = "682298268", Naam = "Smit", Voorl = "R.",
AdresStr = "Kerkstraat", AdresNr = null, AdresPc = "2611GA", AdresPl = "Delft",
Email = "r.smit@example.nl", Telnr = "+31 6 33445566", CorrKanaal = "P",
StatCd = "O", DiplCd = "HBO-BWI", DiplLand = "TR", DiplDat = new DateOnly(2014, 6, 30),
DatOntv = new DateOnly(2026, 4, 22),
MutDat = new DateTime(2026, 4, 22, 15, 50, 0), MutUser = "seed",
},
new()
{
Id = 1008, Bsn = "794413821", Naam = "Vermeulen", Voorl = "M.",
AdresStr = "Julianastraat", AdresNr = "31", AdresPc = "6511PJ", AdresPl = "Nijmegen",
Email = "m.vermeulen@example.nl", Telnr = "+31 6 44556677", CorrKanaal = "P",
StatCd = "O", DiplCd = "WO-RECHT", DiplLand = "FR", DiplDat = new DateOnly(2013, 6, 25),
DatOntv = new DateOnly(2025, 9, 12),
MutDat = new DateTime(2025, 9, 12, 9, 0, 0), MutUser = "seed",
},
new()
{
Id = 1009, Bsn = "469486879", Naam = "Willems", Voorl = "S.",
AdresStr = "Grote Markt", AdresNr = "2", AdresPc = "2511BE", AdresPl = "Den Haag",
Email = "s.willems@example.nl", Telnr = "+31 6 55667788", CorrKanaal = "E",
StatCd = "B", DiplCd = "HBO-ECO", DiplLand = "ES", DiplDat = new DateOnly(2017, 7, 5),
DatOntv = new DateOnly(2025, 11, 3), DatBeoord = new DateOnly(2025, 11, 25),
BeoordRes = "G", BeoordMotiv = "Diploma is gewaardeerd conform de geldende richtlijnen.",
MutDat = new DateTime(2025, 11, 25, 16, 10, 0), MutUser = "seed",
},
new()
{
Id = 1010, Bsn = "349496213", Naam = "Peeters", Voorl = "K.",
AdresStr = "Stationsplein", AdresNr = "10", AdresPc = "5611AZ", AdresPl = "Eindhoven",
Email = "k.peeters@example.nl", Telnr = "040 1122334", CorrKanaal = "P",
StatCd = "A", DiplCd = "MBO-TECH", DiplLand = "IT", DiplDat = new DateOnly(2011, 6, 18),
DatOntv = new DateOnly(2025, 8, 20), DatBeoord = new DateOnly(2025, 9, 10),
BeoordRes = "G", BeoordMotiv = "Alle documenten zijn gecontroleerd en akkoord bevonden.",
MutDat = new DateTime(2025, 9, 10, 10, 40, 0), MutUser = "seed",
},
new()
{
Id = 1011, Bsn = "944293797", Naam = "Dekker", Voorl = "T.",
AdresStr = "Torenlaan", AdresNr = "18", AdresPc = "7511AB", AdresPl = "Enschede",
Email = null, Telnr = "+31 6 66778899", CorrKanaal = "E",
StatCd = "O", DiplCd = "WO-PSY", DiplLand = "PT", DiplDat = new DateOnly(2020, 6, 1),
DatOntv = new DateOnly(2026, 1, 15),
MutDat = new DateTime(2026, 1, 15, 12, 25, 0), MutUser = "seed",
},
new()
{
Id = 1012, Bsn = "380075131", Naam = "Mulder", Voorl = "H.",
AdresStr = "Beukenlaan", AdresNr = "4", AdresPc = "8011MN", AdresPl = "Zwolle",
Email = "h.mulder@example.nl", Telnr = "038 9988776", CorrKanaal = "P",
StatCd = "B", DiplCd = "HBO-EDU", DiplLand = "RO", DiplDat = new DateOnly(2015, 7, 14),
DatOntv = new DateOnly(2025, 12, 1), DatBeoord = new DateOnly(2025, 12, 20),
BeoordRes = "A",
BeoordMotiv = "Buitenlands diploma komt niet overeen met een erkend Nederlands diploma-niveau.",
MutDat = new DateTime(2025, 12, 20, 14, 5, 0), MutUser = "seed",
},
};
await using var transaction = await db.Database.BeginTransactionAsync();
await db.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.AANVR ON");
db.Aanvragen.AddRange(rows);
await db.SaveChangesAsync();
await db.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.AANVR OFF");
await transaction.CommitAsync();
}
}
+17
View File
@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY src/Legacy.Api/ src/Legacy.Api/
# restore+publish combined in one RUN/layer: podman/buildah has a known issue
# where the NuGet global-packages cache uses hardlinks that break when a
# restore layer and a later --no-restore publish layer are committed
# separately, surfacing as a false "package not found" error.
RUN dotnet restore src/Legacy.Api/Legacy.Api.csproj && \
dotnet publish src/Legacy.Api/Legacy.Api.csproj -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Legacy.Api.dll"]
@@ -0,0 +1,127 @@
using Legacy.Api.Data;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace Legacy.Api.Endpoints;
public static class AanvragenEndpoints
{
public static void MapAanvragenEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/aanvragen");
group.MapGet("", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPut("/{id:int}/gegevens", PutGegevens);
group.MapPost("/{id:int}/beoordeling", PostBeoordeling);
group.MapPut("/{id:int}/migratie-vlag", PutMigratieVlag);
}
private static async Task<Ok<List<Aanvraag>>> GetAll(
LegacyDbContext db, string? zoek, string? status)
{
var query = db.Aanvragen.AsQueryable();
if (!string.IsNullOrWhiteSpace(zoek))
{
var term = zoek.ToLower();
query = query.Where(a => a.Naam.ToLower().Contains(term) || a.Bsn.ToLower().Contains(term));
}
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(a => a.StatCd == status);
}
var result = await query.OrderBy(a => a.Id).ToListAsync();
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<Aanvraag>, NotFound>> GetById(LegacyDbContext db, int id)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
return aanvraag is null ? TypedResults.NotFound() : TypedResults.Ok(aanvraag);
}
private static async Task<Results<NoContent, BadRequest<ValidationErrorResponse>, NotFound, Conflict<MigratedResponse>>> PutGegevens(
LegacyDbContext db, int id, GegevensInput input)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
if (aanvraag is null)
{
return TypedResults.NotFound();
}
if (aanvraag.Migrated)
{
return TypedResults.Conflict(
new MigratedResponse("Deze aanvraag wordt beheerd in het nieuwe portaal."));
}
var errors = GegevensValidator.Validate(input);
if (errors.Count > 0)
{
return TypedResults.BadRequest(new ValidationErrorResponse(errors));
}
aanvraag.Naam = input.Surname;
aanvraag.Voorl = input.Initials;
aanvraag.AdresStr = input.Address?.Street;
aanvraag.AdresNr = input.Address?.Number;
aanvraag.AdresPc = input.Address?.PostalCode;
aanvraag.AdresPl = input.Address?.City;
aanvraag.Email = input.Email;
aanvraag.Telnr = input.Phone;
aanvraag.CorrKanaal = string.Equals(input.PreferredChannel, "Email", StringComparison.OrdinalIgnoreCase)
? "E"
: "P";
aanvraag.MutDat = DateTime.Now;
aanvraag.MutUser = "systeem";
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
private static async Task<Results<NoContent, NotFound, Conflict<MigratedResponse>>> PostBeoordeling(
LegacyDbContext db, int id, BeoordelingInput input)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
if (aanvraag is null)
{
return TypedResults.NotFound();
}
if (aanvraag.Migrated)
{
return TypedResults.Conflict(
new MigratedResponse("Deze aanvraag wordt beheerd in het nieuwe portaal."));
}
aanvraag.StatCd = "B";
aanvraag.BeoordRes = input.Res;
aanvraag.BeoordMotiv = input.Motiv;
aanvraag.DatBeoord = DateOnly.FromDateTime(DateTime.Now);
aanvraag.MutDat = DateTime.Now;
aanvraag.MutUser = "systeem";
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
private static async Task<Results<NoContent, NotFound>> PutMigratieVlag(
LegacyDbContext db, int id, MigratieVlagInput input)
{
var aanvraag = await db.Aanvragen.FindAsync(id);
if (aanvraag is null)
{
return TypedResults.NotFound();
}
aanvraag.Migrated = input.Migrated;
aanvraag.MutDat = DateTime.Now;
aanvraag.MutUser = "systeem";
await db.SaveChangesAsync();
return TypedResults.NoContent();
}
}
@@ -0,0 +1,21 @@
namespace Legacy.Api.Endpoints;
public record AdresInput(string? Street, string? Number, string? PostalCode, string? City);
public record GegevensInput(
string Surname,
string? Initials,
AdresInput? Address,
string? Email,
string? Phone,
string PreferredChannel);
public record BeoordelingInput(string Res, string Motiv);
public record MigratieVlagInput(bool Migrated);
public record ValidationError(string Veld, string Code, string Melding);
public record ValidationErrorResponse(IReadOnlyList<ValidationError> Errors);
public record MigratedResponse(string Message);
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
namespace Legacy.Api.Endpoints;
/// <summary>
/// Validates the "gegevens" (particulars) command against the legacy field
/// rules. Collects every violation instead of stopping at the first one.
/// </summary>
public static partial class GegevensValidator
{
public static List<ValidationError> Validate(GegevensInput input)
{
var errors = new List<ValidationError>();
if (string.IsNullOrWhiteSpace(input.Surname))
{
errors.Add(new ValidationError("NAAM", "NAAM_VERPLICHT", "Achternaam is verplicht"));
}
else if (input.Surname.Length > 60)
{
errors.Add(new ValidationError("NAAM", "NAAM_TE_LANG", "Achternaam is te lang"));
}
var street = input.Address?.Street;
var number = input.Address?.Number;
var postalCode = input.Address?.PostalCode;
if (!string.IsNullOrWhiteSpace(street) && string.IsNullOrWhiteSpace(number))
{
errors.Add(new ValidationError("ADRES_NR", "HUISNR_VERPLICHT", "Huisnummer is verplicht"));
}
if (!string.IsNullOrWhiteSpace(postalCode) && !PostcodeRegex().IsMatch(postalCode))
{
errors.Add(new ValidationError("ADRES_PC", "POSTCODE_ONGELDIG", "Postcode ongeldig"));
}
var wantsEmailChannel = string.Equals(input.PreferredChannel, "Email", StringComparison.OrdinalIgnoreCase);
if (wantsEmailChannel && string.IsNullOrWhiteSpace(input.Email))
{
errors.Add(new ValidationError(
"EMAIL", "EMAIL_VERPLICHT_BIJ_KANAAL", "E-mailadres is verplicht bij communicatiekanaal e-mail"));
}
if (!string.IsNullOrWhiteSpace(input.Email) && !EmailRegex().IsMatch(input.Email))
{
errors.Add(new ValidationError("EMAIL", "EMAIL_ONGELDIG", "E-mailadres is ongeldig"));
}
if (!string.IsNullOrWhiteSpace(input.Phone) && !PhoneRegex().IsMatch(input.Phone))
{
errors.Add(new ValidationError("TELNR", "TELNR_ONGELDIG", "Telefoonnummer is ongeldig"));
}
return errors;
}
[GeneratedRegex(@"^[0-9]{4}[A-Z]{2}$")]
private static partial Regex PostcodeRegex();
[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$")]
private static partial Regex EmailRegex();
[GeneratedRegex(@"^[0-9 +]+$")]
private static partial Regex PhoneRegex();
}
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!--
Microsoft.Data.SqlClient requires real culture data (it resolves
culture info while opening a connection) - invariant globalization mode
makes it throw CultureNotFoundException on every connection attempt.
The base runtime image (non-Alpine, non-chiseled) ships ICU, so turning
this off just works.
-->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,147 @@
// <auto-generated />
using System;
using Legacy.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Legacy.Api.Migrations
{
[DbContext(typeof(LegacyDbContext))]
[Migration("20260730160055_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Legacy.Api.Data.Aanvraag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("AANVR_ID");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AdresNr")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("ADRES_NR");
b.Property<string>("AdresPc")
.HasColumnType("char(6)")
.HasColumnName("ADRES_PC");
b.Property<string>("AdresPl")
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("ADRES_PL");
b.Property<string>("AdresStr")
.HasMaxLength(80)
.HasColumnType("nvarchar(80)")
.HasColumnName("ADRES_STR");
b.Property<string>("BeoordMotiv")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasColumnName("BEOORD_MOTIV");
b.Property<string>("BeoordRes")
.HasColumnType("char(1)")
.HasColumnName("BEOORD_RES");
b.Property<string>("Bsn")
.IsRequired()
.HasColumnType("char(9)")
.HasColumnName("BSN");
b.Property<string>("CorrKanaal")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("char(1)")
.HasDefaultValue("P")
.HasColumnName("CORR_KANAAL");
b.Property<DateOnly?>("DatBeoord")
.HasColumnType("date")
.HasColumnName("DAT_BEOORD");
b.Property<DateOnly>("DatOntv")
.HasColumnType("date")
.HasColumnName("DAT_ONTV");
b.Property<string>("DiplCd")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("DIPL_CD");
b.Property<DateOnly?>("DiplDat")
.HasColumnType("date")
.HasColumnName("DIPL_DAT");
b.Property<string>("DiplLand")
.HasColumnType("char(2)")
.HasColumnName("DIPL_LAND");
b.Property<string>("Email")
.HasMaxLength(120)
.HasColumnType("nvarchar(120)")
.HasColumnName("EMAIL");
b.Property<bool>("Migrated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("MIGRATED");
b.Property<DateTime>("MutDat")
.HasColumnType("datetime2")
.HasColumnName("MUT_DAT");
b.Property<string>("MutUser")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)")
.HasColumnName("MUT_USER");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("NAAM");
b.Property<string>("StatCd")
.IsRequired()
.HasColumnType("char(1)")
.HasColumnName("STAT_CD");
b.Property<string>("Telnr")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)")
.HasColumnName("TELNR");
b.Property<string>("Voorl")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("VOORL");
b.HasKey("Id");
b.ToTable("AANVR", "dbo");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Legacy.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "dbo");
migrationBuilder.CreateTable(
name: "AANVR",
schema: "dbo",
columns: table => new
{
AANVR_ID = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BSN = table.Column<string>(type: "char(9)", nullable: false),
NAAM = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
VOORL = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
ADRES_STR = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: true),
ADRES_NR = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
ADRES_PC = table.Column<string>(type: "char(6)", nullable: true),
ADRES_PL = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: true),
EMAIL = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: true),
TELNR = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
CORR_KANAAL = table.Column<string>(type: "char(1)", nullable: false, defaultValue: "P"),
STAT_CD = table.Column<string>(type: "char(1)", nullable: false),
DIPL_CD = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
DIPL_LAND = table.Column<string>(type: "char(2)", nullable: true),
DIPL_DAT = table.Column<DateOnly>(type: "date", nullable: true),
DAT_ONTV = table.Column<DateOnly>(type: "date", nullable: false),
DAT_BEOORD = table.Column<DateOnly>(type: "date", nullable: true),
BEOORD_RES = table.Column<string>(type: "char(1)", nullable: true),
BEOORD_MOTIV = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
MIGRATED = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
MUT_DAT = table.Column<DateTime>(type: "datetime2", nullable: false),
MUT_USER = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AANVR", x => x.AANVR_ID);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AANVR",
schema: "dbo");
}
}
}
@@ -0,0 +1,144 @@
// <auto-generated />
using System;
using Legacy.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Legacy.Api.Migrations
{
[DbContext(typeof(LegacyDbContext))]
partial class LegacyDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Legacy.Api.Data.Aanvraag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("AANVR_ID");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AdresNr")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("ADRES_NR");
b.Property<string>("AdresPc")
.HasColumnType("char(6)")
.HasColumnName("ADRES_PC");
b.Property<string>("AdresPl")
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("ADRES_PL");
b.Property<string>("AdresStr")
.HasMaxLength(80)
.HasColumnType("nvarchar(80)")
.HasColumnName("ADRES_STR");
b.Property<string>("BeoordMotiv")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasColumnName("BEOORD_MOTIV");
b.Property<string>("BeoordRes")
.HasColumnType("char(1)")
.HasColumnName("BEOORD_RES");
b.Property<string>("Bsn")
.IsRequired()
.HasColumnType("char(9)")
.HasColumnName("BSN");
b.Property<string>("CorrKanaal")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("char(1)")
.HasDefaultValue("P")
.HasColumnName("CORR_KANAAL");
b.Property<DateOnly?>("DatBeoord")
.HasColumnType("date")
.HasColumnName("DAT_BEOORD");
b.Property<DateOnly>("DatOntv")
.HasColumnType("date")
.HasColumnName("DAT_ONTV");
b.Property<string>("DiplCd")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("DIPL_CD");
b.Property<DateOnly?>("DiplDat")
.HasColumnType("date")
.HasColumnName("DIPL_DAT");
b.Property<string>("DiplLand")
.HasColumnType("char(2)")
.HasColumnName("DIPL_LAND");
b.Property<string>("Email")
.HasMaxLength(120)
.HasColumnType("nvarchar(120)")
.HasColumnName("EMAIL");
b.Property<bool>("Migrated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("MIGRATED");
b.Property<DateTime>("MutDat")
.HasColumnType("datetime2")
.HasColumnName("MUT_DAT");
b.Property<string>("MutUser")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)")
.HasColumnName("MUT_USER");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)")
.HasColumnName("NAAM");
b.Property<string>("StatCd")
.IsRequired()
.HasColumnType("char(1)")
.HasColumnName("STAT_CD");
b.Property<string>("Telnr")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)")
.HasColumnName("TELNR");
b.Property<string>("Voorl")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)")
.HasColumnName("VOORL");
b.HasKey("Id");
b.ToTable("AANVR", "dbo");
});
#pragma warning restore 612, 618
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using Legacy.Api.Data;
using Legacy.Api.Endpoints;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<LegacyDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Legacy")));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<LegacyDbContext>();
await db.Database.MigrateAsync();
await LegacySeeder.SeedAsync(db);
}
app.MapAanvragenEndpoints();
app.Run();
+31
View File
@@ -0,0 +1,31 @@
namespace Legacy.Web;
/// <summary>
/// Mirrors the JSON shape returned by Legacy.Api - legacy column vocabulary,
/// camelCase over the wire, matched here case-insensitively.
/// </summary>
public class AanvraagDto
{
public int Id { get; set; }
public string Bsn { get; set; } = "";
public string Naam { get; set; } = "";
public string? Voorl { get; set; }
public string? AdresStr { get; set; }
public string? AdresNr { get; set; }
public string? AdresPc { get; set; }
public string? AdresPl { get; set; }
public string? Email { get; set; }
public string? Telnr { get; set; }
public string CorrKanaal { get; set; } = "P";
public string StatCd { get; set; } = "O";
public string? DiplCd { get; set; }
public string? DiplLand { get; set; }
public DateOnly? DiplDat { get; set; }
public DateOnly DatOntv { get; set; }
public DateOnly? DatBeoord { get; set; }
public string? BeoordRes { get; set; }
public string? BeoordMotiv { get; set; }
public bool Migrated { get; set; }
public DateTime MutDat { get; set; }
public string MutUser { get; set; } = "";
}
+14
View File
@@ -0,0 +1,14 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY src/Legacy.Web/ src/Legacy.Web/
# restore+publish combined in one RUN/layer - see Legacy.Api/Dockerfile for why.
RUN dotnet restore src/Legacy.Web/Legacy.Web.csproj && \
dotnet publish src/Legacy.Web/Legacy.Web.csproj -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Legacy.Web.dll"]
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
+65
View File
@@ -0,0 +1,65 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Legacy.Web;
public enum BeoordelingUitkomst
{
Success,
Conflict,
}
/// <summary>
/// Thin HTTP client for Legacy.Api. Legacy.Web never touches the database
/// directly - it only talks to the backend over HTTP, same as any other
/// caller.
/// </summary>
public class LegacyApiClient(HttpClient httpClient)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<List<AanvraagDto>> GetAllAsync(string? zoek, string? status)
{
var query = new List<string>();
if (!string.IsNullOrWhiteSpace(zoek))
{
query.Add($"zoek={Uri.EscapeDataString(zoek)}");
}
if (!string.IsNullOrWhiteSpace(status))
{
query.Add($"status={Uri.EscapeDataString(status)}");
}
var url = "/api/aanvragen" + (query.Count > 0 ? "?" + string.Join("&", query) : "");
var result = await httpClient.GetFromJsonAsync<List<AanvraagDto>>(url, JsonOptions);
return result ?? [];
}
public async Task<AanvraagDto?> GetByIdAsync(int id)
{
var response = await httpClient.GetAsync($"/api/aanvragen/{id}");
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<AanvraagDto>(JsonOptions);
}
public async Task<BeoordelingUitkomst> SubmitBeoordelingAsync(int id, string res, string motiv)
{
var response = await httpClient.PostAsJsonAsync(
$"/api/aanvragen/{id}/beoordeling", new { res, motiv });
if (response.StatusCode == HttpStatusCode.Conflict)
{
return BeoordelingUitkomst.Conflict;
}
response.EnsureSuccessStatusCode();
return BeoordelingUitkomst.Success;
}
}
@@ -0,0 +1,58 @@
@page "/legacy/aanvraag/{id:int}/beoordeling"
@model Legacy.Web.Pages.BeoordelingModel
<h1>Beoordeling aanvraag #@Model.Aanvraag.Id</h1>
@if (Model.Ingediend)
{
<div class="melding">
<p>De beoordeling is verwerkt.</p>
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Terug naar aanvraag</a></p>
</div>
}
else if (Model.Aanvraag.Migrated)
{
<div class="melding">
<p>Deze aanvraag wordt beheerd in het nieuwe portaal.</p>
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Naar nieuw portaal</a></p>
</div>
}
else
{
@if (Model.Conflict)
{
<div class="melding">
<p>Deze aanvraag wordt inmiddels beheerd in het nieuwe portaal. De beoordeling is niet opgeslagen.</p>
<p><a href="/worklist/legacy/@Model.Aanvraag.Id">Naar nieuw portaal</a></p>
</div>
}
else
{
<fieldset>
<legend>Aanvraaggegevens</legend>
<p><label>Naam:</label> @Model.Aanvraag.Naam @Model.Aanvraag.Voorl</p>
<p><label>BSN:</label> @Model.Aanvraag.Bsn</p>
<p><label>Ontvangen:</label> @Model.Aanvraag.DatOntv.ToString("dd-MM-yyyy")</p>
</fieldset>
<form method="post">
<fieldset>
<legend>Beoordeling</legend>
<p>
<label for="res">Uitkomst:</label>
<select id="res" name="Res">
<option value="G" selected="@(Model.Res == "G")">Goedgekeurd</option>
<option value="A" selected="@(Model.Res == "A")">Afgewezen</option>
</select>
</p>
<p>
<label for="motiv">Motivatie:</label><br />
<textarea id="motiv" name="Motiv" rows="5" cols="60">@Model.Motiv</textarea>
</p>
<p>
<input type="submit" value="Beoordeling opslaan" />
</p>
</fieldset>
</form>
}
}
@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Legacy.Web.Pages;
public class BeoordelingModel(LegacyApiClient client) : PageModel
{
public AanvraagDto Aanvraag { get; set; } = null!;
public bool Ingediend { get; set; }
public bool Conflict { get; set; }
[BindProperty]
public string Res { get; set; } = "G";
[BindProperty]
public string Motiv { get; set; } = "";
public async Task<IActionResult> OnGetAsync(int id)
{
var aanvraag = await client.GetByIdAsync(id);
if (aanvraag is null)
{
return NotFound();
}
Aanvraag = aanvraag;
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
var aanvraag = await client.GetByIdAsync(id);
if (aanvraag is null)
{
return NotFound();
}
Aanvraag = aanvraag;
if (Aanvraag.Migrated)
{
// Blocked page is rendered from the razor markup; the write
// endpoint is never called for a migrated case.
return Page();
}
var uitkomst = await client.SubmitBeoordelingAsync(id, Res, Motiv);
if (uitkomst == BeoordelingUitkomst.Conflict)
{
Conflict = true;
Aanvraag = (await client.GetByIdAsync(id))!;
return Page();
}
Ingediend = true;
return Page();
}
}
+59
View File
@@ -0,0 +1,59 @@
@page "/legacy"
@model Legacy.Web.Pages.IndexModel
<h1>Aanvragen diplomawaardering</h1>
<form method="get">
<label for="zoek">Zoeken (naam/BSN):</label>
<input type="text" id="zoek" name="Zoek" value="@Model.Zoek" />
&nbsp;
<label for="status" style="width:auto;">Status:</label>
<select id="status" name="Status">
<option value="">(alle)</option>
<option value="O" selected="@(Model.Status == "O")">open</option>
<option value="B" selected="@(Model.Status == "B")">beoordeeld</option>
<option value="A" selected="@(Model.Status == "A")">afgerond</option>
<option value="X" selected="@(Model.Status == "X")">ingetrokken</option>
</select>
&nbsp;
<input type="submit" value="Filteren" />
</form>
<br />
<table>
<thead>
<tr>
<th>ID</th>
<th>BSN</th>
<th>Naam</th>
<th>Status</th>
<th>Ontvangen</th>
<th>Kanaal</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
@foreach (var a in Model.Aanvragen)
{
<tr class="@(a.Migrated ? "migrated" : "")">
<td>@a.Id</td>
<td>@a.Bsn</td>
<td>@a.Naam @a.Voorl</td>
<td>@Legacy.Web.Pages.IndexModel.StatusLabel(a.StatCd)</td>
<td>@a.DatOntv.ToString("dd-MM-yyyy")</td>
<td>@(a.CorrKanaal == "E" ? "e-mail" : "post")</td>
<td>
@if (a.Migrated)
{
<a href="/worklist/legacy/@a.Id">beheerd in nieuw portaal</a>
}
else
{
<a href="/legacy/aanvraag/@a.Id/beoordeling">Beoordelen</a>
}
</td>
</tr>
}
</tbody>
</table>
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Legacy.Web.Pages;
public class IndexModel(LegacyApiClient client) : PageModel
{
public List<AanvraagDto> Aanvragen { get; set; } = [];
[BindProperty(SupportsGet = true)]
public string? Zoek { get; set; }
[BindProperty(SupportsGet = true)]
public string? Status { get; set; }
public async Task OnGetAsync()
{
Aanvragen = await client.GetAllAsync(Zoek, Status);
}
public static string StatusLabel(string statCd) => statCd switch
{
"O" => "open",
"B" => "beoordeeld",
"A" => "afgerond",
"X" => "ingetrokken",
_ => statCd,
};
}
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Diplomawaardering - Legacy systeem</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
background-color: #d4d0c8;
color: #000000;
margin: 0;
padding: 0 0 24px 0;
}
h1, h2 {
font-family: "Times New Roman", Times, serif;
}
.banner {
background-color: #000080;
color: #ffffff;
padding: 10px 16px;
font-family: "Times New Roman", Times, serif;
font-size: 1.3em;
border-bottom: 2px solid #000000;
}
.content {
padding: 16px;
}
table {
border-collapse: collapse;
width: 100%;
background-color: #ffffff;
}
table, th, td {
border: 1px solid #000000;
}
th, td {
padding: 4px 8px;
text-align: left;
vertical-align: top;
}
th {
background-color: #c0c0c0;
}
tr.migrated {
color: #808080;
background-color: #eeeeee;
}
a {
color: #0000ee;
}
button, input[type=submit] {
background-color: #c0c0c0;
border: 2px outset #808080;
padding: 4px 14px;
font-family: Arial, Helvetica, sans-serif;
font-size: 1em;
}
button:active, input[type=submit]:active {
border-style: inset;
}
fieldset {
border: 1px solid #000000;
margin-bottom: 12px;
}
label {
display: inline-block;
width: 140px;
font-weight: bold;
}
input[type=text], select, textarea {
border: 1px solid #000000;
font-family: Arial, Helvetica, sans-serif;
}
.melding {
border: 1px solid #000000;
background-color: #ffffcc;
padding: 8px;
margin-bottom: 12px;
}
</style>
</head>
<body>
<div class="banner">Diplomawaardering &mdash; Aanvraagregistratie (Legacy systeem)</div>
<div class="content">
@RenderBody()
</div>
</body>
</html>
@@ -0,0 +1,3 @@
@namespace Legacy.Web.Pages
@using Legacy.Web
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+19
View File
@@ -0,0 +1,19 @@
using Legacy.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var legacyApiBaseUrl = builder.Configuration["Services:LegacyApi:BaseUrl"]
?? "http://localhost:8081";
builder.Services.AddHttpClient<LegacyApiClient>(client =>
{
client.BaseAddress = new Uri(legacyApiBaseUrl);
});
var app = builder.Build();
app.MapRazorPages();
app.Run();