The backend half of the sweep RD-18 did for the front end. git blame holds the provenance and stays correct when the code moves; the comment names a closed ticket and tells the reader nothing the sentence around it does not. public/letter.css and LetterHtml.golden.html change together, because the renderer inlines the CSS and the golden file snapshots the result. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
8.1 KiB
C#
211 lines
8.1 KiB
C#
using BigRegister.Domain.People;
|
|
|
|
namespace BigRegister.Api.Data;
|
|
|
|
/// <summary>
|
|
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
|
|
/// 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>
|
|
public sealed record StoredDocument(
|
|
string DocumentId, string LocalId, string CategoryId, string WizardId,
|
|
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
|
|
{
|
|
public bool Linked { get; set; }
|
|
|
|
/// <summary>The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload
|
|
/// registers one — null under the local source. Persisted so the later zaak-link step can
|
|
/// find it without re-uploading; not part of the positional constructor, same reasoning as
|
|
/// <see cref="Linked"/> (every existing `new StoredDocument(...)` call site keeps working).</summary>
|
|
public string? DrcUrl { get; set; }
|
|
}
|
|
|
|
/// <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>
|
|
/// EF Core/SQLite-backed document store + audit log (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) — a real,
|
|
/// elfproef-valid 9-digit BSN (src/app/shared/kernel/bsn.ts's own checksum), distinct from
|
|
/// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer,
|
|
/// a different Dutch identifier scheme). Previously this constant reused that BigNummer value
|
|
/// as a stand-in BSN, which is invalid Dutch-BSN shape: harmless against the local store, but
|
|
/// a real OpenZaak instance rejects it outright — GET /api/v1/aanvragen 500s (`inpBsn` query
|
|
/// filter validation) and every submit's rol-creation POST fails (`inpBsn` max_length) once
|
|
/// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture
|
|
/// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen").
|
|
public const string DemoOwner = "123456782";
|
|
|
|
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)
|
|
{
|
|
using var db = Db.Create();
|
|
db.Documents.Add(doc);
|
|
db.SaveChanges();
|
|
}
|
|
Audit("upload", doc.DocumentId, categoryId, Pii.MaskTail(owner, 3));
|
|
return doc;
|
|
}
|
|
|
|
public static StoredDocument? Get(string documentId)
|
|
{
|
|
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
|
|
/// arrived), an unknown one is still in flight / never started.
|
|
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds, string owner)
|
|
{
|
|
var set = localIds.ToHashSet();
|
|
lock (_gate)
|
|
{
|
|
using var db = Db.Create();
|
|
return db.Documents.Where(d => set.Contains(d.LocalId) && d.Owner == owner).ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>Documents by DocumentId (the beoordeling detail reads an aanvraag's already-
|
|
/// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is
|
|
/// keyed by the wizard's own LocalId instead.</summary>
|
|
public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds)
|
|
{
|
|
var set = documentIds.ToHashSet();
|
|
lock (_gate)
|
|
{
|
|
using var db = Db.Create();
|
|
return db.Documents.Where(d => set.Contains(d.DocumentId)).ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>Which of the given ids do NOT resolve to a document owned by <paramref name="owner"/>
|
|
/// (unknown id or owned by someone else) — named for what it returns (the offending ids), so a
|
|
/// caller can 400 with the specific ids rather than a bare boolean. Guards submit/draft-sync
|
|
/// against a citizen attaching another citizen's upload to their own aanvraag.</summary>
|
|
public static IReadOnlyList<string> ForeignIds(IEnumerable<string> documentIds, string owner)
|
|
{
|
|
var ids = documentIds.ToList();
|
|
lock (_gate)
|
|
{
|
|
using var db = Db.Create();
|
|
var owned = db.Documents.Where(d => ids.Contains(d.DocumentId) && d.Owner == owner)
|
|
.Select(d => d.DocumentId).ToHashSet();
|
|
return ids.Where(id => !owned.Contains(id)).ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>Persist the DRC url an OpenZaak upload registered for a document.</summary>
|
|
public static void SetDrcUrl(string documentId, string drcUrl)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
using var db = Db.Create();
|
|
var d = db.Documents.Find(documentId);
|
|
if (d is null) return;
|
|
d.DrcUrl = drcUrl;
|
|
db.SaveChanges();
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
{
|
|
var d = db.Documents.Find(id);
|
|
if (d is not null) d.Linked = true;
|
|
}
|
|
db.SaveChanges();
|
|
}
|
|
}
|
|
|
|
public enum DeleteResult { Ok, NotFound, Linked }
|
|
|
|
/// User delete: owner-scoped; blocked once linked to a finalised submission.
|
|
public static DeleteResult DeleteOwned(string documentId, string owner)
|
|
{
|
|
string categoryId;
|
|
lock (_gate)
|
|
{
|
|
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;
|
|
db.Documents.Remove(d);
|
|
db.SaveChanges();
|
|
}
|
|
Audit("delete-user", documentId, categoryId, Pii.MaskTail(owner, 3));
|
|
return DeleteResult.Ok;
|
|
}
|
|
|
|
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
|
|
/// review so a caseworker is notified. Returns false if the document is unknown.
|
|
public static bool AdminDelete(string documentId, string actor)
|
|
{
|
|
string categoryId;
|
|
lock (_gate)
|
|
{
|
|
using var db = Db.Create();
|
|
var d = db.Documents.Find(documentId);
|
|
if (d is null) return false;
|
|
categoryId = d.CategoryId;
|
|
db.Documents.Remove(d);
|
|
db.SaveChanges();
|
|
}
|
|
Audit("delete-admin", documentId, categoryId, actor);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Append one metadata-only audit row. <paramref name="actor"/> must arrive
|
|
/// **already redacted** (BIO-005) — the two citizen call sites pass
|
|
/// <see cref="Pii.MaskTail"/> of the owner BSN, `delete-admin` passes the literal
|
|
/// `"admin"`. The unmasked BSN lives only in <see cref="StoredDocument.Owner"/>, which is
|
|
/// the authorization key and stays untouched. Masking here instead would have to guess
|
|
/// which actors are BSNs and which are role names.</summary>
|
|
public static void Audit(string action, string documentId, string categoryId, string 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)
|
|
{
|
|
using var db = Db.Create();
|
|
return db.AuditEntries.ToList();
|
|
}
|
|
}
|
|
}
|
|
}
|