using BigRegister.Domain.People; namespace BigRegister.Api.Data; /// /// 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. /// 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; } /// 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 /// (every existing `new StoredDocument(...)` call site keeps working). public string? DrcUrl { get; set; } } /// 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. public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor) { public long Id { get; init; } } /// /// 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). /// 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 ByLocalIds(IEnumerable 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(); } } /// Documents by DocumentId (the beoordeling detail reads an aanvraag's already- /// linked documents) — the DocumentId-keyed counterpart of , which is /// keyed by the wizard's own LocalId instead. public static IReadOnlyList ByIds(IEnumerable documentIds) { var set = documentIds.ToHashSet(); lock (_gate) { using var db = Db.Create(); return db.Documents.Where(d => set.Contains(d.DocumentId)).ToList(); } } /// Which of the given ids do NOT resolve to a document owned by /// (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. public static IReadOnlyList ForeignIds(IEnumerable 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(); } } /// Persist the DRC url an OpenZaak upload registered for a document. 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 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; } /// Append one metadata-only audit row. must arrive /// **already redacted** (BIO-005) — the two citizen call sites pass /// of the owner BSN, `delete-admin` passes the literal /// `"admin"`. The unmasked BSN lives only in , which is /// the authorization key and stays untouched. Masking here instead would have to guess /// which actors are BSNs and which are role names. 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 AuditLog { get { lock (_gate) { using var db = Db.Create(); return db.AuditEntries.ToList(); } } } }