feat(fp): WP-22 — durable persistence (SQLite/EF Core)
Applications, documents (+ audit log) and the brief move off static in-memory Dictionaries onto a real SQLite file via EF Core, so demo data survives a process restart or `docker compose restart api` for the first time. The three stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public signatures and static-class shape — no DI, no async ripple into Program.cs's minimal-API handlers — each method just opens a short-lived AppDbContext via Db.Create() under the same lock it already had. Opaque nested shapes (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as JSON text columns rather than redesigned into relational tables, matching the existing "don't interpret it" posture. Found two things the WP's own text got wrong, corrected in docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which stay in-memory) so there's no seed step; and no new docker-compose volume is needed since the existing bind mount already covers the SQLite file — verified against this environment's real podman-backed compose stack, not just by reading the file. Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own transitive default bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption advisory); found and fixed a real xUnit test race where concurrent test-class hosts stomped a shared static connection-string field, fixed by disabling cross-class test parallelization rather than adding DI the stores don't otherwise need. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -65,7 +65,9 @@ jobs:
|
|||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
# Smoke-level Playwright run against the REAL FE+backend (WP-19) — a fresh
|
# Smoke-level Playwright run against the REAL FE+backend (WP-19) — a fresh
|
||||||
# backend process per run, so in-memory state from a prior run never leaks in.
|
# runner checkout per run, so there's no bigregister.db (WP-22, gitignored)
|
||||||
|
# left over from a prior run to leak state in; the backend creates + migrates
|
||||||
|
# an empty one on this boot, same as a fresh clone always has.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ update this file.
|
|||||||
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
||||||
view their registration, apply for re-registration). Angular 22, standalone,
|
view their registration, apply for re-registration). Angular 22, standalone,
|
||||||
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
|
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
|
||||||
Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed
|
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
||||||
through an NSwag-generated typed client. The FE renders the backend's decisions.
|
typed client. The FE renders the backend's decisions. Reference data mimicking
|
||||||
|
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
||||||
|
persist to a SQLite file via EF Core (WP-22) — `docs/backlog/WP-22-durable-persistence.md`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -167,8 +167,9 @@ degrade to an instant navigation.
|
|||||||
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
|
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
|
||||||
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
||||||
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
||||||
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
|
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
|
||||||
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
|
||||||
|
consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
||||||
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
|
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
|
||||||
**dev-only** — it is not wired into production builds.
|
**dev-only** — it is not wired into production builds.
|
||||||
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
||||||
@@ -194,9 +195,11 @@ We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 2
|
|||||||
|
|
||||||
### Deliberately out of scope (POC)
|
### Deliberately out of scope (POC)
|
||||||
|
|
||||||
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, NgRx,
|
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
|
||||||
licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
|
Server — SQLite persists applications/documents/the brief + a real audit table,
|
||||||
itself _is_ implemented.) i18n's build seam is proven (see above) but
|
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
|
||||||
|
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
|
||||||
|
proven (see above) but
|
||||||
production-quality translation, a runtime locale switcher, and RTL/pluralization
|
production-quality translation, a runtime locale switcher, and RTL/pluralization
|
||||||
edge cases are not — the `en` file is demo-quality, and locale is a build-time
|
edge cases are not — the `en` file is demo-quality, and locale is a build-time
|
||||||
choice, not a switch in the running app.
|
choice, not a switch in the running app.
|
||||||
|
|||||||
5
backend/.gitignore
vendored
5
backend/.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|
||||||
|
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
|
||||||
|
bigregister.db
|
||||||
|
bigregister.db-shm
|
||||||
|
bigregister.db-wal
|
||||||
|
|||||||
@@ -4,8 +4,18 @@ The backend that hosts the **business rules** for the BIG-register portal. The
|
|||||||
frontend renders the decisions this service computes; it does not recompute them
|
frontend renders the decisions this service computes; it does not recompute them
|
||||||
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
|
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
|
||||||
|
|
||||||
No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`),
|
No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
|
||||||
but the endpoints, DTOs, status codes and error envelope are production-shaped.
|
notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs,
|
||||||
|
status codes and error envelope are production-shaped.
|
||||||
|
|
||||||
|
**Applications, documents and the brief persist** to a SQLite file
|
||||||
|
(`src/BigRegister.Api/bigregister.db`, EF Core-backed — `Data/AppDbContext.cs`,
|
||||||
|
`Data/Db.cs`) created and migrated on first run; restarting the process (or
|
||||||
|
`docker compose restart api` — the existing `./backend:/src` bind mount already
|
||||||
|
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
|
||||||
|
reset demo data back to empty, the same state a fresh clone starts from. This is
|
||||||
|
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
|
||||||
|
`docs/backlog/WP-22-durable-persistence.md`.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
"swagger"
|
"swagger"
|
||||||
],
|
],
|
||||||
"rollForward": false
|
"rollForward": false
|
||||||
|
},
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.9",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,15 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||||
|
<!-- Pin over EF Core Sqlite's own transitive default (2.1.11): that version
|
||||||
|
bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption
|
||||||
|
advisory (GHSA-2m69-gcr7-jv3q). 3.0.3 bundles a patched SQLite. -->
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
63
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal file
63
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core/SQLite persistence for the three stores that used to be static
|
||||||
|
/// in-memory dictionaries (WP-22): <see cref="Aanvraag"/>, <see cref="StoredDocument"/>
|
||||||
|
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes
|
||||||
|
/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
|
||||||
|
/// JSON text columns rather than redesigned into relational tables — the backend
|
||||||
|
/// already treats them as opaque (see BriefStore's own header comment), so a JSON
|
||||||
|
/// column matches that "don't interpret it" posture with zero schema redesign,
|
||||||
|
/// per this WP's own decision to relocate shapes, not redesign them.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<StoredDocument> Documents => Set<StoredDocument>();
|
||||||
|
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||||
|
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
||||||
|
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<StoredDocument>().HasKey(d => d.DocumentId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<AuditEntry>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => a.Id);
|
||||||
|
e.Property(a => a.Id).ValueGeneratedOnAdd();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Aanvraag>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => a.Id);
|
||||||
|
e.Property(a => a.Draft).HasConversion(DraftConverter);
|
||||||
|
e.Property(a => a.DocumentIds).HasConversion(Json<List<string>>());
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<BriefEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(b => b.BriefId);
|
||||||
|
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
|
||||||
|
e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
|
||||||
|
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
|
||||||
|
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON
|
||||||
|
// text. `.Clone()` on read detaches the element from the short-lived JsonDocument
|
||||||
|
// that parsed it (same rule ApplicationStore.SyncDraft already follows for the
|
||||||
|
// request body — an uncloned element is invalid once its JsonDocument is GC'd).
|
||||||
|
private static readonly ValueConverter<JsonElement?, string?> DraftConverter = new(
|
||||||
|
v => v.HasValue ? v.Value.GetRawText() : null,
|
||||||
|
v => string.IsNullOrEmpty(v) ? (JsonElement?)null : JsonDocument.Parse(v).RootElement.Clone());
|
||||||
|
|
||||||
|
private static ValueConverter<T, string> Json<T>() => new(
|
||||||
|
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||||
|
v => JsonSerializer.Deserialize<T>(v, (JsonSerializerOptions?)null)!);
|
||||||
|
}
|
||||||
@@ -29,34 +29,48 @@ public sealed class Aanvraag
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
|
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
|
||||||
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
|
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
|
||||||
/// locks if it ever serves load.
|
/// tolerates only one writer at a time anyway, and this was already a single
|
||||||
|
/// coarse gate before the DB existed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ApplicationStore
|
public static class ApplicationStore
|
||||||
{
|
{
|
||||||
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
||||||
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
||||||
|
|
||||||
private static readonly Dictionary<string, Aanvraag> _apps = new();
|
|
||||||
private static readonly object _gate = new();
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
public static Aanvraag Create(string type, string owner)
|
public static Aanvraag Create(string type, string owner)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
|
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
|
||||||
lock (_gate) _apps[a.Id] = a;
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
db.Applications.Add(a);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Aanvraag? Get(string id, string owner)
|
public static Aanvraag? Get(string id, string owner)
|
||||||
{
|
{
|
||||||
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
var a = db.Applications.Find(id);
|
||||||
|
return a is not null && a.Owner == owner ? a : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<Aanvraag> List(string owner)
|
public static IReadOnlyList<Aanvraag> List(string owner)
|
||||||
{
|
{
|
||||||
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
return db.Applications.Where(a => a.Owner == owner).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
||||||
@@ -64,12 +78,15 @@ public static class ApplicationStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
|
using var db = Db.Create();
|
||||||
|
var a = db.Applications.Find(id);
|
||||||
|
if (a is null || a.Owner != owner || a.Submitted) return false;
|
||||||
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
|
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
|
||||||
a.StepIndex = stepIndex;
|
a.StepIndex = stepIndex;
|
||||||
a.StepCount = stepCount;
|
a.StepCount = stepCount;
|
||||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
db.SaveChanges();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,9 +98,12 @@ public static class ApplicationStore
|
|||||||
List<string> docs;
|
List<string> docs;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
|
using var db = Db.Create();
|
||||||
|
var a = db.Applications.Find(id);
|
||||||
|
if (a is null || a.Owner != owner) return false;
|
||||||
docs = a.DocumentIds.ToList();
|
docs = a.DocumentIds.ToList();
|
||||||
_apps.Remove(id);
|
db.Applications.Remove(a);
|
||||||
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
||||||
return true;
|
return true;
|
||||||
@@ -96,7 +116,9 @@ public static class ApplicationStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
|
using var db = Db.Create();
|
||||||
|
var a = db.Applications.Find(id);
|
||||||
|
if (a is null || a.Owner != owner || a.Submitted) return null;
|
||||||
a.Submitted = true;
|
a.Submitted = true;
|
||||||
a.SubmittedAt = DateTimeOffset.UtcNow;
|
a.SubmittedAt = DateTimeOffset.UtcNow;
|
||||||
a.UpdatedAt = a.SubmittedAt.Value;
|
a.UpdatedAt = a.SubmittedAt.Value;
|
||||||
@@ -104,6 +126,7 @@ public static class ApplicationStore
|
|||||||
a.AutoApprovable = autoApprovable;
|
a.AutoApprovable = autoApprovable;
|
||||||
a.Reden = reject;
|
a.Reden = reject;
|
||||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||||
|
db.SaveChanges();
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The letter (brief) — one demo brief per owner, created from a template on first
|
/// The letter (brief) — one demo brief per owner, created from a template on first
|
||||||
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. The status machine and
|
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
|
||||||
/// its guards live here (the server is authoritative for transitions); the FE mirrors
|
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
|
||||||
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
|
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
|
||||||
/// stub does not interpret it (no server-side placeholder linting in this slice).
|
/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not
|
||||||
|
/// interpret it (no server-side placeholder linting in this slice).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BriefEntity
|
public sealed class BriefEntity
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,8 @@ public sealed class BriefEntity
|
|||||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ponytail: one global lock, same as before this WP — SQLite tolerates
|
||||||
|
/// only one writer at a time anyway, and this was already a single coarse gate.</summary>
|
||||||
public static class BriefStore
|
public static class BriefStore
|
||||||
{
|
{
|
||||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||||
@@ -32,16 +36,18 @@ public static class BriefStore
|
|||||||
|
|
||||||
public enum Outcome { Ok, Forbidden, Conflict }
|
public enum Outcome { Ok, Forbidden, Conflict }
|
||||||
|
|
||||||
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
|
|
||||||
private static readonly object _gate = new();
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
public static BriefEntity GetOrCreate(string owner)
|
public static BriefEntity GetOrCreate(string owner)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
|
using var db = Db.Create();
|
||||||
|
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||||
|
if (existing is not null) return existing;
|
||||||
var created = BriefSeed.NewBrief(owner);
|
var created = BriefSeed.NewBrief(owner);
|
||||||
_byOwner[owner] = created;
|
db.Briefs.Add(created);
|
||||||
|
db.SaveChanges();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,11 +58,14 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
using var db = Db.Create();
|
||||||
|
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||||
|
if (e is null) return (Outcome.Conflict, null);
|
||||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||||
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||||
e.Sections = sections.ToList();
|
e.Sections = sections.ToList();
|
||||||
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,10 +74,13 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
using var db = Db.Create();
|
||||||
|
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||||
|
if (e is null) return (Outcome.Conflict, null);
|
||||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||||
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||||
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,9 +97,12 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
using var db = Db.Create();
|
||||||
|
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||||
|
if (e is null) return (Outcome.Conflict, null);
|
||||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +110,11 @@ public static class BriefStore
|
|||||||
/// Test seam: clear the demo brief between tests.
|
/// Test seam: clear the demo brief between tests.
|
||||||
public static void Reset()
|
public static void Reset()
|
||||||
{
|
{
|
||||||
lock (_gate) _byOwner.Clear();
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
db.Briefs.ExecuteDelete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
|
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
|
||||||
@@ -104,9 +123,11 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_byOwner.Remove(owner);
|
using var db = Db.Create();
|
||||||
|
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
|
||||||
var created = BriefSeed.NewBrief(owner);
|
var created = BriefSeed.NewBrief(owner);
|
||||||
_byOwner[owner] = created;
|
db.Briefs.Add(created);
|
||||||
|
db.SaveChanges();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,10 +141,13 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
using var db = Db.Create();
|
||||||
|
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||||
|
if (e is null) return (Outcome.Conflict, null);
|
||||||
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
||||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||||
e.Status = next();
|
e.Status = next();
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
backend/src/BigRegister.Api/Data/Db.cs
Normal file
27
backend/src/BigRegister.Api/Data/Db.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for short-lived <see cref="AppDbContext"/> instances. The three stores
|
||||||
|
/// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape
|
||||||
|
/// predates WP-22 and this WP keeps it — so they can't take a constructor-injected
|
||||||
|
/// DbContext; each store method opens one here, uses it, and disposes it under its
|
||||||
|
/// own lock instead.
|
||||||
|
/// </summary>
|
||||||
|
public static class Db
|
||||||
|
{
|
||||||
|
public static string ConnectionString { get; set; } = "Data Source=bigregister.db";
|
||||||
|
|
||||||
|
public static AppDbContext Create() =>
|
||||||
|
new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(ConnectionString).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets `dotnet ef migrations add` construct a context at design time
|
||||||
|
/// without booting the full app (no DI registration exists for AppDbContext —
|
||||||
|
/// see Db.Create above for why). Auto-discovered by EF Core's tooling.</summary>
|
||||||
|
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args) => Db.Create();
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
|
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
|
||||||
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
|
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a
|
||||||
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
|
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
|
||||||
/// serialized into a JSON response; only the dedicated content endpoint streams them.
|
/// serialized into a JSON response; only the dedicated content endpoint streams them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -13,33 +13,48 @@ public sealed record StoredDocument(
|
|||||||
public bool Linked { get; set; }
|
public bool Linked { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
|
/// <summary>Id is EF Core's auto-increment key — not part of the positional
|
||||||
|
/// constructor, so every existing `new AuditEntry(at, action, ...)` call site
|
||||||
|
/// keeps working unchanged; EF Core assigns it on insert.</summary>
|
||||||
|
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor)
|
||||||
|
{
|
||||||
|
public long Id { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
|
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
|
||||||
/// for a single-process demo store; swap for per-key locks if it ever serves load.
|
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
|
||||||
/// The audit log holds metadata only (never file content or other PII).
|
/// one writer at a time anyway, and this process already serialized all access
|
||||||
|
/// through a single gate, so it now doubles as a coarse single-writer guard for
|
||||||
|
/// the DB file. The audit log holds metadata only (never file content or other PII).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DocumentStore
|
public static class DocumentStore
|
||||||
{
|
{
|
||||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||||
public const string DemoOwner = "19012345601";
|
public const string DemoOwner = "19012345601";
|
||||||
|
|
||||||
private static readonly Dictionary<string, StoredDocument> _docs = new();
|
|
||||||
private static readonly List<AuditEntry> _audit = new();
|
|
||||||
private static readonly object _gate = new();
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
|
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
|
||||||
{
|
{
|
||||||
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
|
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
|
||||||
lock (_gate) _docs[doc.DocumentId] = doc;
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
db.Documents.Add(doc);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
Audit("upload", doc.DocumentId, categoryId, owner);
|
Audit("upload", doc.DocumentId, categoryId, owner);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StoredDocument? Get(string documentId)
|
public static StoredDocument? Get(string documentId)
|
||||||
{
|
{
|
||||||
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
return db.Documents.Find(documentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status for the poll-on-return pattern: a known localId is "complete" (it
|
/// Status for the poll-on-return pattern: a known localId is "complete" (it
|
||||||
@@ -47,15 +62,26 @@ public static class DocumentStore
|
|||||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
||||||
{
|
{
|
||||||
var set = localIds.ToHashSet();
|
var set = localIds.ToHashSet();
|
||||||
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark digital documents as linked to a finalised submission (blocks user delete).
|
/// Mark digital documents as linked to a finalised submission (blocks user delete).
|
||||||
public static void Link(IEnumerable<string> documentIds)
|
public static void Link(IEnumerable<string> documentIds)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
foreach (var id in documentIds)
|
foreach (var id in documentIds)
|
||||||
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
|
{
|
||||||
|
var d = db.Documents.Find(id);
|
||||||
|
if (d is not null) d.Linked = true;
|
||||||
|
}
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum DeleteResult { Ok, NotFound, Linked }
|
public enum DeleteResult { Ok, NotFound, Linked }
|
||||||
@@ -66,10 +92,13 @@ public static class DocumentStore
|
|||||||
string categoryId;
|
string categoryId;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
|
using var db = Db.Create();
|
||||||
|
var d = db.Documents.Find(documentId);
|
||||||
|
if (d is null || d.Owner != owner) return DeleteResult.NotFound;
|
||||||
if (d.Linked) return DeleteResult.Linked;
|
if (d.Linked) return DeleteResult.Linked;
|
||||||
categoryId = d.CategoryId;
|
categoryId = d.CategoryId;
|
||||||
_docs.Remove(documentId);
|
db.Documents.Remove(d);
|
||||||
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
Audit("delete-user", documentId, categoryId, owner);
|
Audit("delete-user", documentId, categoryId, owner);
|
||||||
return DeleteResult.Ok;
|
return DeleteResult.Ok;
|
||||||
@@ -82,9 +111,12 @@ public static class DocumentStore
|
|||||||
string categoryId;
|
string categoryId;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_docs.TryGetValue(documentId, out var d)) return false;
|
using var db = Db.Create();
|
||||||
|
var d = db.Documents.Find(documentId);
|
||||||
|
if (d is null) return false;
|
||||||
categoryId = d.CategoryId;
|
categoryId = d.CategoryId;
|
||||||
_docs.Remove(documentId);
|
db.Documents.Remove(d);
|
||||||
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
Audit("delete-admin", documentId, categoryId, actor);
|
Audit("delete-admin", documentId, categoryId, actor);
|
||||||
return true;
|
return true;
|
||||||
@@ -92,11 +124,23 @@ public static class DocumentStore
|
|||||||
|
|
||||||
public static void Audit(string action, string documentId, string categoryId, string actor)
|
public static void Audit(string action, string documentId, string categoryId, string actor)
|
||||||
{
|
{
|
||||||
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
db.AuditEntries.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<AuditEntry> AuditLog
|
public static IReadOnlyList<AuditEntry> AuditLog
|
||||||
{
|
{
|
||||||
get { lock (_gate) return _audit.ToList(); }
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
return db.AuditEntries.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
195
backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs
generated
Normal file
195
backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260704190822_Initial")]
|
||||||
|
partial class Initial
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AutoApprovable")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Draft")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Reden")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Referentie")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("StepCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("StepIndex")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Submitted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Actor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("At")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CategoryId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AuditEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("BriefId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Beroep")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DrafterId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Placeholders")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Sections")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("BriefId");
|
||||||
|
|
||||||
|
b.HasIndex("Owner")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Briefs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("DocumentId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CategoryId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Linked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LocalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UploadedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WizardId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("DocumentId");
|
||||||
|
|
||||||
|
b.ToTable("Documents");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Initial : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Applications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Owner = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Draft = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
StepIndex = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
StepCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
DocumentIds = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Referentie = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
AutoApprovable = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
Reden = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Submitted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||||
|
SubmittedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Applications", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AuditEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
At = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||||
|
Action = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Actor = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AuditEntries", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Briefs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BriefId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Owner = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Beroep = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
TemplateId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
DrafterId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Placeholders = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Sections = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Briefs", x => x.BriefId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Documents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
LocalId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
WizardId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
SizeBytes = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
ContentType = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Content = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||||
|
Owner = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
UploadedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||||
|
Linked = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Documents", x => x.DocumentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Briefs_Owner",
|
||||||
|
table: "Briefs",
|
||||||
|
column: "Owner",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Applications");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AuditEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Briefs");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Documents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AutoApprovable")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Draft")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Reden")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Referentie")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("StepCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("StepIndex")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Submitted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Actor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("At")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CategoryId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AuditEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("BriefId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Beroep")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DrafterId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Placeholders")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Sections")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("BriefId");
|
||||||
|
|
||||||
|
b.HasIndex("Owner")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Briefs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("DocumentId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CategoryId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Linked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LocalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UploadedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WizardId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("DocumentId");
|
||||||
|
|
||||||
|
b.ToTable("Documents");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using BigRegister.Domain.Documents;
|
|||||||
using BigRegister.Domain.Intake;
|
using BigRegister.Domain.Intake;
|
||||||
using BigRegister.Domain.Registrations;
|
using BigRegister.Domain.Registrations;
|
||||||
using BigRegister.Domain.Submissions;
|
using BigRegister.Domain.Submissions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Console;
|
using Microsoft.Extensions.Logging.Console;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -29,8 +30,23 @@ const string SpaCors = "spa";
|
|||||||
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||||
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
||||||
|
|
||||||
|
// WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static
|
||||||
|
// classes that open their own short-lived AppDbContext per call (see Db.Create),
|
||||||
|
// not DI-injected, so there's no builder.Services.AddDbContext here. Configuring
|
||||||
|
// the connection string still goes through IConfiguration so tests/deployments can
|
||||||
|
// override it (ConnectionStrings:AppDb) without touching this file.
|
||||||
|
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only
|
||||||
|
// reference fixtures (registration/diplomas/notes — untouched by this WP, still
|
||||||
|
// static in-memory), Applications/Documents/Briefs never had seed data — they
|
||||||
|
// started empty and accumulated through normal use before this WP too. A fresh
|
||||||
|
// SQLite file just starts empty again, same as the old in-memory dictionaries did.
|
||||||
|
using (var db = Db.Create())
|
||||||
|
db.Database.Migrate();
|
||||||
|
|
||||||
// Every request gets a correlation id (client-supplied X-Correlation-Id if present,
|
// Every request gets a correlation id (client-supplied X-Correlation-Id if present,
|
||||||
// else generated), pushed into the logging scope for every log line the request
|
// else generated), pushed into the logging scope for every log line the request
|
||||||
// produces (not just the Submit helper's) and echoed back as a response header for
|
// produces (not just the Submit helper's) and echoed back as a response header for
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
|
|
||||||
namespace BigRegister.Tests;
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace BigRegister.Tests;
|
|||||||
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
||||||
/// header: absent = drafter, "approver" = a different identity.
|
/// header: absent = drafter, "approver" = a different identity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
|
|
||||||
namespace BigRegister.Tests;
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
|
|
||||||
namespace BigRegister.Tests;
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
public class IdempotencyTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
|||||||
41
backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs
Normal file
41
backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
// WP-22's stores read a single static Db.ConnectionString (there's no DI, matching
|
||||||
|
// their pre-WP-22 static-Dictionary shape — see Data/Db.cs). That's correct for a
|
||||||
|
// real single-instance process, but xUnit's default parallel-across-classes
|
||||||
|
// execution would run multiple WebApplicationFactory hosts concurrently in this
|
||||||
|
// ONE test process, each overwriting that same static field with its own temp-file
|
||||||
|
// path — a real race (caught as "table already exists" from two Migrate() calls
|
||||||
|
// interleaving on whichever file won the race), not a hypothetical one. Serializing
|
||||||
|
// test classes is the fix, not a redesign of the stores for a test-only concern.
|
||||||
|
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP-22 moved Applications/Documents/Briefs off in-memory dictionaries onto a real
|
||||||
|
/// SQLite file (see Data/Db.cs). Unlike static dictionaries, a shared file path
|
||||||
|
/// would let concurrent test classes' WebApplicationFactory instances hit the same
|
||||||
|
/// file at once — xUnit runs different test classes in parallel by default, and
|
||||||
|
/// SQLite tolerates only one writer at a time, so that's a real "database is
|
||||||
|
/// locked" flake risk, not a hypothetical one. Every test class below points at its
|
||||||
|
/// own throwaway file instead, deleted when the factory (and its class's tests) are
|
||||||
|
/// done — the same one-store-per-class isolation the old dictionaries gave for free.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db");
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
|
||||||
|
builder.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}");
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
if (!disposing) return;
|
||||||
|
File.Delete(_dbPath);
|
||||||
|
File.Delete(_dbPath + "-shm"); // ponytail: best-effort — WAL sidecar files if SQLite created any.
|
||||||
|
File.Delete(_dbPath + "-wal");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ services:
|
|||||||
- ASPNETCORE_ENVIRONMENT=Development
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
volumes:
|
volumes:
|
||||||
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
||||||
|
# WP-22: no separate volume needed for the SQLite file — `dotnet run` sets
|
||||||
|
# its cwd to the project dir (src/BigRegister.Api), which this bind mount
|
||||||
|
# already covers, so bigregister.db lands on the host and survives
|
||||||
|
# `docker compose restart api` / `down` + `up` for free (gitignored).
|
||||||
- ./backend:/src:z
|
- ./backend:/src:z
|
||||||
- api-bin:/src/src/BigRegister.Api/bin
|
- api-bin:/src/src/BigRegister.Api/bin
|
||||||
- api-obj:/src/src/BigRegister.Api/obj
|
- api-obj:/src/src/BigRegister.Api/obj
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-22 — Durable persistence (optional tier)
|
# WP-22 — Durable persistence (optional tier)
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -98,22 +98,30 @@ speculatively.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
|
- [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
|
||||||
`Data/*.cs` for application/document/brief state.
|
`Data/*.cs` for application/document/brief state.
|
||||||
- [ ] Every existing backend test passes unchanged (signatures didn't change).
|
- [x] Every existing backend test passes unchanged (signatures didn't change).
|
||||||
- [ ] Restarting the backend process preserves previously created applications,
|
84/84 green, stable across repeated runs (see Deviations for a real race this
|
||||||
|
surfaced).
|
||||||
|
- [x] Restarting the backend process preserves previously created applications,
|
||||||
documents, and brief drafts (manually verified).
|
documents, and brief drafts (manually verified).
|
||||||
- [ ] The audit log survives a restart and is queryable (even if no new endpoint
|
- [x] The audit log survives a restart and is queryable (even if no new endpoint
|
||||||
exposes it yet — persistence is the bar, not a new audit UI).
|
exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries`
|
||||||
- [ ] `docker compose up` with the new volume also survives a container restart.
|
is a real table now; not separately re-verified across restart beyond the
|
||||||
|
applications/brief checks (same store mechanism, same `Db.Create()` seam).
|
||||||
|
- [x] `docker compose up` with a container restart preserves data — **no new
|
||||||
|
volume** turned out to be needed (see Deviations).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
`cd backend && dotnet test`. Manual: `dotnet run --project src/BigRegister.Api`,
|
`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
|
||||||
create an application via the FE or a curl request, kill and restart the process,
|
src/BigRegister.Api`, created an application via curl, killed and restarted the
|
||||||
confirm `GET /api/v1/applications` still returns it. Repeat with `docker compose up`
|
process, confirmed `GET /api/v1/applications` still returned it (repeated for the
|
||||||
|
brief). Repeated the same check against the **real** `docker compose up` stack
|
||||||
- `docker compose restart api`.
|
(this environment has an actual podman-backed compose, not a mock) — created an
|
||||||
|
application via `curl localhost:5000`, ran `docker compose restart api`, confirmed
|
||||||
|
it survived, and confirmed on the host that `backend/src/BigRegister.Api/bigregister.db`
|
||||||
|
is the file being written (gitignored, not tracked).
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
@@ -131,3 +139,47 @@ synchronously; converting to `async`/`await` may ripple further than "just the
|
|||||||
Data/ layer" if minimal-API handlers aren't already `async`. Check this before
|
Data/ layer" if minimal-API handlers aren't already `async`. Check this before
|
||||||
starting and budget for handler signature changes (still not a _behavior_ change,
|
starting and budget for handler signature changes (still not a _behavior_ change,
|
||||||
but a wider diff than the Files section implies if handlers need `async` added).
|
but a wider diff than the Files section implies if handlers need `async` added).
|
||||||
|
|
||||||
|
**Resolved**: didn't ripple at all. EF Core's SQLite provider fully supports
|
||||||
|
synchronous APIs (`.Find()`, `.ToList()`, `.SaveChanges()`, `.ExecuteDelete()`); every
|
||||||
|
store method stayed synchronous, so `Program.cs`'s minimal-API handlers needed zero
|
||||||
|
changes. The stores stayed **static classes** with no DI — each method opens its
|
||||||
|
own short-lived `AppDbContext` via a small `Db.Create()` factory (`Data/Db.cs`) under
|
||||||
|
the same `lock (_gate)` each store already had, which now doubles as a single-writer
|
||||||
|
guard for the SQLite file (SQLite tolerates only one writer at a time anyway).
|
||||||
|
|
||||||
|
## Deviations from the plan
|
||||||
|
|
||||||
|
- **No SeedData → DB seed step.** The WP's own "Decisions"/"Files" sections assumed
|
||||||
|
`SeedData` populates the three stores and needs a "seed if empty" migration. It
|
||||||
|
doesn't — `SeedData` only backs the read-only BRP/DUO-mimicking GET endpoints
|
||||||
|
(registration, person, diplomas, notes), which stay in-memory and are untouched by
|
||||||
|
this WP. Applications/Documents/Briefs never had seed data; they started empty
|
||||||
|
before this WP and still do. One less step than planned.
|
||||||
|
- **No new docker-compose volume.** The existing `./backend:/src` bind mount already
|
||||||
|
covers `bigregister.db` (it's written under `src/BigRegister.Api/`, itself inside
|
||||||
|
the bind-mounted tree — confirmed empirically, not just by reading the compose
|
||||||
|
file), so a container restart already persists it for free. Added a comment
|
||||||
|
instead of a redundant `volumes:` entry.
|
||||||
|
- **Opaque nested shapes (wizard draft, brief sections/placeholders/status) became
|
||||||
|
JSON text columns**, not new relational tables — matches the WP's own "relocate
|
||||||
|
the shape, don't redesign it" instruction and the existing "the backend treats
|
||||||
|
brief content as opaque" posture.
|
||||||
|
- **Found and fixed a real test race, not a hypothetical one.** The stores read a
|
||||||
|
single static `Db.ConnectionString` (matching their pre-WP-22 static-Dictionary
|
||||||
|
shape — no DI). xUnit's default parallel-across-classes execution ran multiple
|
||||||
|
`WebApplicationFactory` hosts concurrently in the one test process, each
|
||||||
|
overwriting that same static field with its own temp-file path — caught as a
|
||||||
|
`SQLite Error 1: 'table "Applications" already exists'` from two `Migrate()` calls
|
||||||
|
interleaving on whichever file won the race. Fixed with
|
||||||
|
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
|
||||||
|
(`TestWebApplicationFactory.cs`) rather than redesigning the stores' DI shape for
|
||||||
|
a test-only concern. Reran `dotnet test` 3× in a row to confirm the race was
|
||||||
|
actually gone, not just less likely.
|
||||||
|
- **Pinned `SQLitePCLRaw.bundle_e_sqlite3` to 3.0.3** — `Microsoft.EntityFrameworkCore.Sqlite`
|
||||||
|
10.0.9's own transitive default (2.1.11) bundles a pre-3.50.2 SQLite with a known
|
||||||
|
high-severity memory-corruption advisory (GHSA-2m69-gcr7-jv3q); 3.0.3 bundles a
|
||||||
|
patched one and built/tested cleanly as a drop-in.
|
||||||
|
- **`dotnet-ef` added to the existing `backend/dotnet-tools.json`** (not a new
|
||||||
|
`.config/dotnet-tools.json`) — this repo already keeps its one CLI tool manifest
|
||||||
|
there (`swashbuckle.aspnetcore.cli`); matched that convention.
|
||||||
|
|||||||
Reference in New Issue
Block a user