Upload feature (a): BFF endpoints + category config + tests
- Domain/Documents: server-owned category config per wizard + authoritative
type/size validation (DocumentRules).
- Data/DocumentStore: in-memory metadata store (no file bytes/PII) + audit log;
user delete (owner-scoped, 409 once linked), admin delete (role seam via
X-Admin header), link-on-submit, poll-by-localId status.
- Program.cs: GET /uploads/categories, POST /uploads (multipart, excluded from
OpenAPI — hand-written on FE), GET /uploads/status, DELETE /uploads/{id},
DELETE /admin/uploads/{id}. Submit links digital docs + records post-delivery.
- Contracts extended (DocumentRefDto on registratie/herregistratie submit);
regenerated NSwag client + swagger.json (drift check stays green).
- Tests: 16 new (endpoints + DocumentRules); dotnet test 44/44.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
101
backend/src/BigRegister.Api/Data/DocumentStore.cs
Normal file
101
backend/src/BigRegister.Api/Data/DocumentStore.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Stored document metadata. The demo deliberately keeps NO file content — only
|
||||
/// metadata — which is enough for status/delete/link and avoids holding PII bytes
|
||||
/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId.
|
||||
/// </summary>
|
||||
public sealed record StoredDocument(
|
||||
string DocumentId, string LocalId, string CategoryId, string WizardId,
|
||||
string FileName, long SizeBytes, string Owner, DateTimeOffset UploadedAt)
|
||||
{
|
||||
public bool Linked { get; set; }
|
||||
}
|
||||
|
||||
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
|
||||
|
||||
/// <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).
|
||||
/// </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, long sizeBytes, string owner)
|
||||
{
|
||||
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, sizeBytes, owner, DateTimeOffset.UtcNow);
|
||||
lock (_gate) _docs[doc.DocumentId] = doc;
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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)
|
||||
{
|
||||
var set = localIds.ToHashSet();
|
||||
lock (_gate) return _docs.Values.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)
|
||||
foreach (var id in documentIds)
|
||||
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
|
||||
if (d.Linked) return DeleteResult.Linked;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
}
|
||||
Audit("delete-user", documentId, categoryId, owner);
|
||||
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)
|
||||
{
|
||||
if (!_docs.TryGetValue(documentId, out var d)) return false;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
}
|
||||
Audit("delete-admin", documentId, categoryId, actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Audit(string action, string documentId, string categoryId, string actor)
|
||||
{
|
||||
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AuditEntry> AuditLog
|
||||
{
|
||||
get { lock (_gate) return _audit.ToList(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user