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:
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>
|
||||
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
|
||||
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
|
||||
/// locks if it ever serves load.
|
||||
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
|
||||
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
|
||||
/// tolerates only one writer at a time anyway, and this was already a single
|
||||
/// coarse gate before the DB existed.
|
||||
/// </summary>
|
||||
public static class ApplicationStore
|
||||
{
|
||||
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
||||
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
||||
|
||||
private static readonly Dictionary<string, Aanvraag> _apps = new();
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static Aanvraag Create(string type, string owner)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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.
|
||||
@@ -64,12 +78,15 @@ public static class ApplicationStore
|
||||
{
|
||||
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.StepIndex = stepIndex;
|
||||
a.StepCount = stepCount;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -81,9 +98,12 @@ public static class ApplicationStore
|
||||
List<string> docs;
|
||||
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();
|
||||
_apps.Remove(id);
|
||||
db.Applications.Remove(a);
|
||||
db.SaveChanges();
|
||||
}
|
||||
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
||||
return true;
|
||||
@@ -96,7 +116,9 @@ public static class ApplicationStore
|
||||
{
|
||||
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.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
a.UpdatedAt = a.SubmittedAt.Value;
|
||||
@@ -104,6 +126,7 @@ public static class ApplicationStore
|
||||
a.AutoApprovable = autoApprovable;
|
||||
a.Reden = reject;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
db.SaveChanges();
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// its guards live here (the server is authoritative for transitions); the FE mirrors
|
||||
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
|
||||
/// stub does not interpret it (no server-side placeholder linting in this slice).
|
||||
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
|
||||
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
|
||||
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
|
||||
/// 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>
|
||||
public sealed class BriefEntity
|
||||
{
|
||||
@@ -24,6 +26,8 @@ public sealed class BriefEntity
|
||||
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
|
||||
{
|
||||
// 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 }
|
||||
|
||||
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static BriefEntity GetOrCreate(string owner)
|
||||
{
|
||||
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);
|
||||
_byOwner[owner] = created;
|
||||
db.Briefs.Add(created);
|
||||
db.SaveChanges();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -52,11 +58,14 @@ public static class BriefStore
|
||||
{
|
||||
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 (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||
e.Sections = sections.ToList();
|
||||
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
@@ -65,10 +74,13 @@ public static class BriefStore
|
||||
{
|
||||
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 (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
@@ -85,9 +97,12 @@ public static class BriefStore
|
||||
{
|
||||
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);
|
||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +110,11 @@ public static class BriefStore
|
||||
/// Test seam: clear the demo brief between tests.
|
||||
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
|
||||
@@ -104,9 +123,11 @@ public static class BriefStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_byOwner.Remove(owner);
|
||||
using var db = Db.Create();
|
||||
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
db.Briefs.Add(created);
|
||||
db.SaveChanges();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -120,10 +141,13 @@ public static class BriefStore
|
||||
{
|
||||
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 (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||
e.Status = next();
|
||||
db.SaveChanges();
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
|
||||
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
|
||||
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
|
||||
/// (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
|
||||
/// serialized into a JSON response; only the dedicated content endpoint streams them.
|
||||
/// </summary>
|
||||
@@ -13,33 +13,48 @@ public sealed record StoredDocument(
|
||||
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>
|
||||
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
|
||||
/// for a single-process demo store; swap for per-key locks if it ever serves load.
|
||||
/// The audit log holds metadata only (never file content or other PII).
|
||||
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
|
||||
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
|
||||
/// 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>
|
||||
public static class DocumentStore
|
||||
{
|
||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||
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();
|
||||
|
||||
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);
|
||||
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);
|
||||
return doc;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -47,15 +62,26 @@ public static class DocumentStore
|
||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
||||
{
|
||||
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).
|
||||
public static void Link(IEnumerable<string> documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
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 }
|
||||
@@ -66,10 +92,13 @@ public static class DocumentStore
|
||||
string categoryId;
|
||||
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;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
db.Documents.Remove(d);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("delete-user", documentId, categoryId, owner);
|
||||
return DeleteResult.Ok;
|
||||
@@ -82,9 +111,12 @@ public static class DocumentStore
|
||||
string categoryId;
|
||||
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;
|
||||
_docs.Remove(documentId);
|
||||
db.Documents.Remove(d);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("delete-admin", documentId, categoryId, actor);
|
||||
return true;
|
||||
@@ -92,11 +124,23 @@ public static class DocumentStore
|
||||
|
||||
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
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user