From 228dbcc0c227cc15f1bf0d9f7854f6fbc38dca43 Mon Sep 17 00:00:00 2001 From: Shubh Gupta Date: Tue, 28 Jul 2026 12:13:12 +0530 Subject: [PATCH 1/4] feat: file retry for failed files (admin only) --- .../Controllers/FilesController.cs | 27 ++ .../Files/FileDetailsServiceTests.cs | 65 +++- .../Security/CrossTenantAccessTests.cs | 33 +- DocAnalytics.Service/Files/FileDetailsDtos.cs | 10 + .../Files/FileDetailsService.cs | 147 +++++++-- .../Files/IFileDetailsService.cs | 7 + .../files/file-details.component.html | 295 +++++++++--------- .../features/files/file-details.component.ts | 17 +- .../app/features/files/file-details.models.ts | 8 + .../features/files/file-details.service.ts | 48 ++- 10 files changed, 431 insertions(+), 226 deletions(-) diff --git a/DocAnalytics.Api/Controllers/FilesController.cs b/DocAnalytics.Api/Controllers/FilesController.cs index 4ce1646..0e846a5 100644 --- a/DocAnalytics.Api/Controllers/FilesController.cs +++ b/DocAnalytics.Api/Controllers/FilesController.cs @@ -53,5 +53,32 @@ public async Task GetLogs(Guid id, CancellationToken ct) return File(Encoding.UTF8.GetBytes(log.Content), "text/plain", log.FileName); } + /// + /// Resets a Failed file to Queued and re-enqueues it for processing. + /// Admin-only — stacks with the class-level DataAccess policy. + /// + // POST /api/v1/files/{id}/retry + [HttpPost("{id:guid}/retry")] + [Authorize(Roles = "Admin")] + public async Task RetryFile(Guid id, CancellationToken ct) + { + try + { + var result = await _service.RetryFileAsync(id, ct); + + if (result is null) + return NotFound( + ApiResponse.Fail( + "NOT_FOUND", "File not found or access denied.")); + + return Ok(ApiResponse.Ok(result)); + } + catch (InvalidOperationException ex) + { + return BadRequest( + ApiResponse.Fail("INVALID_STATE", ex.Message)); + } + } + } diff --git a/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs b/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs index 55a265b..0c5a988 100644 --- a/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs +++ b/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs @@ -1,4 +1,6 @@ +using DocAnalytics.Domain.Common; using DocAnalytics.Domain.Entities; +using DocAnalytics.Service.Extraction; using DocAnalytics.Service.Files; using DocAnalytics.Service.Tests.Support; using MockQueryable.Moq; @@ -8,6 +10,8 @@ namespace DocAnalytics.Service.Tests.Files; public class FileDetailsServiceTests { + // ── helpers ──────────────────────────────────────────────────────────── + private static Mock Ctx( FileRecord[] files, FileStepHistory[] steps, ErrorCatalog[] catalog) { @@ -18,10 +22,21 @@ public class FileDetailsServiceTests return ctx; } + // Wrap construction so existing tests don't need to know about the new deps + // (IExtractionQueue + ICurrentUser are only used by RetryFileAsync, not these tests) + private static FileDetailsService Svc(Mock ctx) => + new(ctx.Object, + new Mock().Object, + new Mock().Object); + + // ── tests ─────────────────────────────────────────────────────────────── + [Fact] public async Task GetFileDetailsAsync_returns_null_when_file_missing() { - var sut = new FileDetailsService(Ctx(Array.Empty(), Array.Empty(), Array.Empty()).Object); + var sut = Svc(Ctx(Array.Empty(), + Array.Empty(), + Array.Empty())); Assert.Null(await sut.GetFileDetailsAsync(Guid.NewGuid())); } @@ -29,28 +44,46 @@ public async Task GetFileDetailsAsync_returns_null_when_file_missing() public async Task GetFileDetailsAsync_maps_history_with_remediation() { var fileId = Guid.NewGuid(); - var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", Status = "Failed", CurrentStep = "Validate" } }; + var files = new[] + { + new FileRecord + { + Id = fileId, FileName = "a.pdf", + Status = "Failed", CurrentStep = "Validate", + }, + }; var steps = new[] { - new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload", Status = "Success", StartedAt = DateTime.UtcNow.AddMinutes(-2) }, - new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", StartedAt = DateTime.UtcNow.AddMinutes(-1) }, + new FileStepHistory + { + Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload", + Status = "Success", StartedAt = DateTime.UtcNow.AddMinutes(-2), + }, + new FileStepHistory + { + Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", + Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", + StartedAt = DateTime.UtcNow.AddMinutes(-1), + }, }; var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } }; - var dto = await new FileDetailsService(Ctx(files, steps, catalog).Object).GetFileDetailsAsync(fileId); + var dto = await Svc(Ctx(files, steps, catalog)).GetFileDetailsAsync(fileId); Assert.NotNull(dto); Assert.Equal("a.pdf", dto!.FileInfo.Name); Assert.Equal(2, dto.History.Count); var failed = dto.History.Single(h => h.Step == "Validate"); Assert.Equal("Fix it", failed.Error!.SuggestedFix); - Assert.Null(dto.History.Single(h => h.Step == "Upload").Error); // success → no error block + Assert.Null(dto.History.Single(h => h.Step == "Upload").Error); } [Fact] public async Task GetFileLogsAsync_returns_null_when_file_missing() { - var sut = new FileDetailsService(Ctx(Array.Empty(), Array.Empty(), Array.Empty()).Object); + var sut = Svc(Ctx(Array.Empty(), + Array.Empty(), + Array.Empty())); Assert.Null(await sut.GetFileLogsAsync(Guid.NewGuid())); } @@ -58,14 +91,26 @@ public async Task GetFileLogsAsync_returns_null_when_file_missing() public async Task GetFileLogsAsync_builds_downloadable_log() { var fileId = Guid.NewGuid(); - var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", Status = "Failed", CurrentStep = "Validate" } }; + var files = new[] + { + new FileRecord + { + Id = fileId, FileName = "a.pdf", + Status = "Failed", CurrentStep = "Validate", + }, + }; var steps = new[] { - new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", StartedAt = DateTime.UtcNow }, + new FileStepHistory + { + Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", + Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", + StartedAt = DateTime.UtcNow, + }, }; var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } }; - var log = await new FileDetailsService(Ctx(files, steps, catalog).Object).GetFileLogsAsync(fileId); + var log = await Svc(Ctx(files, steps, catalog)).GetFileLogsAsync(fileId); Assert.NotNull(log); Assert.Equal($"file_{fileId}_log.txt", log!.FileName); diff --git a/DocAnalytics.Service.Tests/Security/CrossTenantAccessTests.cs b/DocAnalytics.Service.Tests/Security/CrossTenantAccessTests.cs index 9fe8f66..a30584e 100644 --- a/DocAnalytics.Service.Tests/Security/CrossTenantAccessTests.cs +++ b/DocAnalytics.Service.Tests/Security/CrossTenantAccessTests.cs @@ -1,7 +1,9 @@ using DocAnalytics.Domain.Entities; +using DocAnalytics.Service.Extraction; using DocAnalytics.Service.Files; using DocAnalytics.Service.Invoices; using DocAnalytics.Service.Tests.Support; +using Moq; namespace DocAnalytics.Service.Tests.Security; @@ -13,16 +15,17 @@ public async Task GetFileDetailsAsync_returns_null_for_other_tenant_file_id() // ── Arrange ── var tenantA = Guid.NewGuid(); var siteA = Guid.NewGuid(); - var tenantB = Guid.NewGuid(); var siteB = Guid.NewGuid(); - using var db = InMemoryDb.Create(new TestCurrentUser + // Extract so we can pass the same instance to FileDetailsService + var currentUser = new TestCurrentUser { TenantId = tenantA, SiteId = siteA, Role = "Viewer", - }); + }; + using var db = InMemoryDb.Create(currentUser); var txnB = new Transaction { @@ -38,7 +41,6 @@ public async Task GetFileDetailsAsync_returns_null_for_other_tenant_file_id() CompletedCount = 0, SubmittedAt = DateTime.UtcNow.AddMinutes(-10), LastUpdatedAt = DateTime.UtcNow, - CompletedAt = null, }; var fileB = new FileRecord @@ -52,8 +54,6 @@ public async Task GetFileDetailsAsync_returns_null_for_other_tenant_file_id() Status = "Failed", CurrentStep = "Validate", FileSizeBytes = 1234, - ExtractionStatus = null, - ExtractionConfidence = null, StorageKey = "s3/key", CreatedAt = DateTime.UtcNow.AddMinutes(-9), LastUpdatedAt = DateTime.UtcNow.AddMinutes(-1), @@ -62,12 +62,10 @@ public async Task GetFileDetailsAsync_returns_null_for_other_tenant_file_id() db.Transactions.Add(txnB); db.Files.Add(fileB); - // Risky table: NOT tenant-scoped (no tenant_id/site_id columns) db.FileStepHistory.Add(new FileStepHistory { Id = Guid.NewGuid(), FileId = fileB.Id, - DocumentTypeId = null, StepName = "Validate", Status = "Failed", StartedAt = DateTime.UtcNow.AddMinutes(-5), @@ -78,13 +76,16 @@ public async Task GetFileDetailsAsync_returns_null_for_other_tenant_file_id() await db.SaveChangesAsync(); - var svc = new FileDetailsService(db); + // Pass currentUser + a no-op queue mock to the updated constructor + var svc = new FileDetailsService( + db, + new Mock().Object, + currentUser); // ── Act ── var result = await svc.GetFileDetailsAsync(fileB.Id); - // ── Assert ── - // Service returns null => controller returns 404. This proves "no existence leak". + // ── Assert ── no existence leak → 404 at controller Assert.Null(result); } @@ -92,11 +93,8 @@ public async Task GetFileDetailsAsync_returns_null_for_other_tenant_file_id() public async Task GetInvoiceForFileAsync_returns_null_for_other_tenant_file_id() { // ── Arrange ── - var tenantA = Guid.NewGuid(); - var siteA = Guid.NewGuid(); - - var tenantB = Guid.NewGuid(); - var siteB = Guid.NewGuid(); + var tenantA = Guid.NewGuid(); var siteA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); var siteB = Guid.NewGuid(); using var db = InMemoryDb.Create(new TestCurrentUser { @@ -143,8 +141,6 @@ public async Task GetInvoiceForFileAsync_returns_null_for_other_tenant_file_id() db.Transactions.Add(txnB); db.Files.Add(fileB); - // Risky table: InvoiceHeader (in your repo it DOES implement ITenantScoped per repomix, - // but we still prove "can't be reached cross-tenant"). db.InvoiceHeaders.Add(new InvoiceHeader { Id = Guid.NewGuid(), @@ -170,7 +166,6 @@ public async Task GetInvoiceForFileAsync_returns_null_for_other_tenant_file_id() FileId = fileB.Id, TenantId = tenantB, SiteId = siteB, - ItemCategoryId = null, LineNumber = 1, Description = "Should not leak", Quantity = 1, diff --git a/DocAnalytics.Service/Files/FileDetailsDtos.cs b/DocAnalytics.Service/Files/FileDetailsDtos.cs index e407496..1726e33 100644 --- a/DocAnalytics.Service/Files/FileDetailsDtos.cs +++ b/DocAnalytics.Service/Files/FileDetailsDtos.cs @@ -56,3 +56,13 @@ public sealed class FileLogDto /// The plain-text step-by-step trace. public string Content { get; set; } = null!; // plain-text trace } + +// ── POST /api/v1/files/{id}/retry ── +/// Response returned after successfully re-queuing a failed file. +public sealed class RetryFileResponseDto +{ + public Guid FileId { get; init; } + public string NewStatus { get; init; } = "Queued"; + public Guid TransactionId { get; init; } + public string TransactionState { get; init; } = string.Empty; +} diff --git a/DocAnalytics.Service/Files/FileDetailsService.cs b/DocAnalytics.Service/Files/FileDetailsService.cs index 300a107..39370f3 100644 --- a/DocAnalytics.Service/Files/FileDetailsService.cs +++ b/DocAnalytics.Service/Files/FileDetailsService.cs @@ -1,38 +1,50 @@ using System.Text; -using DocAnalytics.Data; // AppDbContext +using DocAnalytics.Data; +using DocAnalytics.Domain.Common; // ICurrentUser +using DocAnalytics.Domain.Entities; // ActivityLog, Transaction +using ActivityLogEntry = DocAnalytics.Domain.Entities.ActivityLog; // disambiguate from Service.ActivityLog namespace +using DocAnalytics.Service.Extraction; using Microsoft.EntityFrameworkCore; namespace DocAnalytics.Service.Files; -/// Default implementation: file timeline details and downloadable step logs. +/// Default implementation. public sealed class FileDetailsService : IFileDetailsService { private readonly AppDbContext _db; - public FileDetailsService(AppDbContext db) => _db = db; + private readonly IExtractionQueue _queue; + private readonly ICurrentUser _currentUser; - /// - // GET /api/v1/files/{id}/details — joins Files + FileStepHistory + ErrorCatalog - public async Task GetFileDetailsAsync(Guid fileId, CancellationToken ct = default) + public FileDetailsService( + AppDbContext db, + IExtractionQueue queue, + ICurrentUser currentUser) + { + _db = db; + _queue = queue; + _currentUser = currentUser; + } + + // ── GET /api/v1/files/{id}/details ────────────────────────────────────── + public async Task GetFileDetailsAsync( + Guid fileId, CancellationToken ct = default) { - // 1) Load the file SCOPED to this tenant/site (global query filter auto-applies). var file = await _db.Files.AsNoTracking() .FirstOrDefaultAsync(f => f.Id == fileId, ct); - if (file is null) return null; // 404 for both not-found AND other-tenant (no existence leak) + if (file is null) return null; - // 2) Pull this file's steps in timeline order. (FileStepHistory is NOT tenant-scoped, - // so we always drive from the already-scoped file id — isolation stays intact.) var steps = await _db.FileStepHistory.AsNoTracking() .Where(s => s.FileId == fileId) .OrderBy(s => s.StartedAt) .ThenBy(s => s.Id) .ToListAsync(ct); - // 3) Soft-join to ErrorCatalog BY error_code (one round-trip, no N+1). - var codes = steps.Where(s => s.ErrorCode != null) - .Select(s => s.ErrorCode!) - .Distinct() - .ToList(); + var codes = steps + .Where(s => s.ErrorCode != null) + .Select(s => s.ErrorCode!) + .Distinct() + .ToList(); var remediation = codes.Count == 0 ? new Dictionary() @@ -40,15 +52,14 @@ public sealed class FileDetailsService : IFileDetailsService .Where(e => codes.Contains(e.ErrorCode)) .ToDictionaryAsync(e => e.ErrorCode, e => e.RemediationMsg, ct); - // 4) Shape the nested DTO. - var dto = new FileDetailDto + return new FileDetailDto { FileInfo = new FileInfoDto { Id = file.Id, Name = file.FileName, CurrentStatus = file.Status, - CurrentStep = file.CurrentStep + CurrentStep = file.CurrentStep, }, History = steps.Select(s => new StepHistoryDto { @@ -59,17 +70,15 @@ public sealed class FileDetailsService : IFileDetailsService { Code = s.ErrorCode, Message = s.ErrorMessage, - SuggestedFix = remediation.TryGetValue(s.ErrorCode, out var fix) ? fix : null - } - }).ToList() + SuggestedFix = remediation.TryGetValue(s.ErrorCode, out var fix) ? fix : null, + }, + }).ToList(), }; - - return dto; } - /// - // GET /api/v1/files/{id}/logs — downloadable step-by-step trace - public async Task GetFileLogsAsync(Guid fileId, CancellationToken ct = default) + // ── GET /api/v1/files/{id}/logs ────────────────────────────────────────── + public async Task GetFileLogsAsync( + Guid fileId, CancellationToken ct = default) { var file = await _db.Files.AsNoTracking() .FirstOrDefaultAsync(f => f.Id == fileId, ct); @@ -82,7 +91,12 @@ public sealed class FileDetailsService : IFileDetailsService .ThenBy(s => s.Id) .ToListAsync(ct); - var codes = steps.Where(s => s.ErrorCode != null).Select(s => s.ErrorCode!).Distinct().ToList(); + var codes = steps + .Where(s => s.ErrorCode != null) + .Select(s => s.ErrorCode!) + .Distinct() + .ToList(); + var remediation = codes.Count == 0 ? new Dictionary() : await _db.ErrorCatalog.AsNoTracking() @@ -100,7 +114,8 @@ public sealed class FileDetailsService : IFileDetailsService foreach (var s in steps) { - var ts = (s.StartedAt ?? s.CompletedAt)?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "(no timestamp)"; + var ts = (s.StartedAt ?? s.CompletedAt)?.ToString("yyyy-MM-ddTHH:mm:ssZ") + ?? "(no timestamp)"; sb.AppendLine($"[{ts}] {s.StepName,-12} {s.Status}"); if (s.ErrorCode is not null) { @@ -113,10 +128,84 @@ public sealed class FileDetailsService : IFileDetailsService return new FileLogDto { FileName = $"file_{file.Id}_log.txt", - Content = sb.ToString() + Content = sb.ToString(), }; } + // ── POST /api/v1/files/{id}/retry ──────────────────────────────────────── + public async Task RetryFileAsync( + Guid fileId, CancellationToken ct = default) + { + // Global query filter auto-scopes to current tenant + site. + // Include Transaction so we can update its counters in the same SaveChanges call. + var file = await _db.Files + .Include(f => f.Transaction) + .FirstOrDefaultAsync(f => f.Id == fileId, ct); + + if (file is null) + return null; // 404 — also hides existence of other-tenant files + + if (file.Status != "Failed") + throw new InvalidOperationException( + "Only files in 'Failed' state can be retried."); + var now = DateTime.UtcNow; + var oldStatus = file.Status; + // 1. Reset the file to Queued (FileStepHistory rows are intentionally kept) + file.Status = "Queued"; + file.CurrentStep = "Queued"; + file.LastUpdatedAt = now; + + // 2. Adjust Transaction counters and recompute batch state + var txn = file.Transaction + ?? throw new InvalidOperationException("Parent transaction not found."); + + txn.FailedCount = Math.Max(0, txn.FailedCount - 1); + txn.ProcessingCount++; + RecomputeTransactionState(txn, now); + + // 3. Write audit trail entry + _db.Add(new ActivityLogEntry + { + Id = Guid.NewGuid(), + TenantId = file.TenantId, + SiteId = file.SiteId, + EventType = "FILE_RETRY", + EntityType = "File", + EntityId = file.Id, + EntityName = file.FileName, + OldState = oldStatus, + NewState = "Queued", + TriggeredBy = _currentUser.UserId.ToString(), + CreatedAt = now, + }); + + await _db.SaveChangesAsync(ct); + + // 4. Push the file back into the extraction pipeline + await _queue.EnqueueAsync(fileId, ct); + + return new RetryFileResponseDto + { + FileId = fileId, + NewStatus = "Queued", + TransactionId = txn.Id, + TransactionState = txn.State, + }; + } + + // ── helpers ────────────────────────────────────────────────────────────── + /// + /// Mirrors the recompute logic from ExtractionWorker — kept here so the + /// service layer owns the rule in one place. + /// + private static void RecomputeTransactionState(Transaction t, DateTime at) + { + var settled = t.CompletedCount + t.FailedCount; + var allDone = settled >= t.TotalFiles; + t.State = allDone ? (t.FailedCount > 0 ? "Failed" : "Completed") : "Processing"; + t.LastUpdatedAt = at; + t.CompletedAt = allDone ? at : null; + } } diff --git a/DocAnalytics.Service/Files/IFileDetailsService.cs b/DocAnalytics.Service/Files/IFileDetailsService.cs index a5dfbd1..cdeddda 100644 --- a/DocAnalytics.Service/Files/IFileDetailsService.cs +++ b/DocAnalytics.Service/Files/IFileDetailsService.cs @@ -14,4 +14,11 @@ public interface IFileDetailsService /// Cancellation token. /// The log content, or null if the file does not exist. Task GetFileLogsAsync(Guid fileId, CancellationToken ct = default); + + /// + /// Resets a Failed file back to Queued, adjusts Transaction counters, writes an + /// audit log entry, and re-enqueues the file for processing. Admin-only operation. + /// + /// The updated state, or null if the file was not found. + Task RetryFileAsync(Guid fileId, CancellationToken ct = default); } diff --git a/docanalytics-web/src/app/features/files/file-details.component.html b/docanalytics-web/src/app/features/files/file-details.component.html index e3c333c..6f8948c 100644 --- a/docanalytics-web/src/app/features/files/file-details.component.html +++ b/docanalytics-web/src/app/features/files/file-details.component.html @@ -1,169 +1,168 @@
- ← Back to batch - @if (svc.detailLoading()) { -
Loading file…
+
Loading file…
} @else if (svc.detailError()) { -
- {{ svc.detailError() }} - -
+
+ {{ svc.detailError() }} + +
} @else if (info(); as fi) { -
-
-

File

-

{{ fi.name }}

-

- Current step: {{ fi.current_step }} -

-
-
- - - -
-
+
+
+

File

+

{{ fi.name }}

+

+ Current step: {{ fi.current_step }} +

+
- -
-

Processing timeline

- @if (history().length === 0) { -
No steps recorded for this file.
- } @else { -
    - @for (s of history(); track $index) { -
  1. - -
    -
    - {{ s.step }} - {{ s.status }} - {{ s.ts ? (s.ts | date: 'medium') : '—' }} -
    - @if (s.error; as e) { -
    -
    - {{ e.code }}{{ e.message ? ' — ' + e.message : '' }} -
    - @if (e.suggested_fix) { -
    💡 Suggested fix: {{ e.suggested_fix }}
    - } -
    - } -
    -
  2. - } -
+
+ + + + @if (isAdmin() && fi.current_status === 'Failed') { + } + + +
+
+ + + @if (svc.retryError()) { +

⚠ {{ svc.retryError() }}

} - +
-

Invoice

- @if (svc.invoiceLoading()) { -
Loading invoice…
- } @else if (!svc.hasInvoice()) { -
This file could not be found.
- } @else if (svc.invoiceError()) { -
- {{ svc.invoiceError() }} - -
+

Processing timeline

+ @if (history().length === 0) { +
No steps recorded for this file.
} @else { - - @if (svc.invoice()?.header; as h) { -
-
- Invoice #{{ h.invoice_number ?? '—' }} -
-
- Date{{ h.invoice_date ?? '—' }} -
-
- Seller{{ h.seller ?? '—' }} +
    + @for (s of history(); track $index) { +
  1. + +
    +
    + {{ s.step }} + {{ s.status }} + {{ s.ts ? (s.ts | date: 'medium') : '—' }}
    -
    - Buyer{{ h.buyer ?? '—' }} -
    -
    - Currency{{ h.currency ?? '—' }} + @if (s.error; as e) { +
    +
    + {{ e.code }}{{ e.message ? ' — ' + e.message : '' }} +
    + @if (e.suggested_fix) { +
    💡 Suggested fix: {{ e.suggested_fix }}
    + }
    + }
    +
  2. } +
+ } +
+ } - @if (items().length === 0) { -
No line items — this file has no extracted invoice items.
- } @else { -
- - - - - - - - - - - - - - @for (li of items(); track li.line_number) { - - - - - - - - - - } - - - @if (svc.invoice()?.header; as h) { - @if (h.discount) { - - - - - - } - @if (h.tax) { - - - - - - } - @if (h.shipping) { - - - - - - } - } - - - - - - -
#DescriptionCategoryQtyUnit priceLine totalConfidence
{{ li.line_number }}{{ li.description }}{{ li.category_name ?? 'Uncategorized' }}{{ num(li.quantity, 3) }}{{ num(li.unit_price, 2) }}{{ num(li.line_total, 2) }}{{ pct(li.confidence) }}
Discount− {{ h.currency }} {{ num(h.discount, 2) }}
Tax{{ h.currency }} {{ num(h.tax, 2) }}
Shipping{{ h.currency }} {{ num(h.shipping, 2) }}
Grand total - {{ svc.invoice()?.header?.currency }} - {{ num(svc.invoice()?.grand_total ?? 0, 2) }} -
-
- } + +
+

Invoice

+ @if (svc.invoiceLoading()) { +
Loading invoice…
+ } @else if (!svc.hasInvoice()) { +
This file could not be found.
+ } @else if (svc.invoiceError()) { +
+ {{ svc.invoiceError() }} + +
+ } @else { + @if (svc.invoice()?.header; as h) { +
+
Invoice #{{ h.invoice_number ?? '—' }}
+
Date{{ h.invoice_date ?? '—' }}
+
Seller{{ h.seller ?? '—' }}
+
Buyer{{ h.buyer ?? '—' }}
+
Currency{{ h.currency ?? '—' }}
+
+ } + @if (items().length === 0) { +
No line items extracted.
+ } @else { +
+ + + + + + + + + + + + + + @for (li of items(); track li.line_number) { + + + + + + + + + + } + + + @if (svc.invoice()?.header; as h) { + @if (h.discount) { + + + + + + } + @if (h.tax) { + + + + + + } + @if (h.shipping) { + + + + + + } + } + + + + + + +
#DescriptionCategoryQtyUnit priceLine totalConfidence
{{ li.line_number }}{{ li.description }}{{ li.category_name ?? 'Uncategorized' }}{{ num(li.quantity, 3) }}{{ num(li.unit_price, 2) }}{{ num(li.line_total, 2) }}{{ pct(li.confidence) }}
Discount− {{ h.currency }} {{ num(h.discount, 2) }}
Tax{{ h.currency }} {{ num(h.tax, 2) }}
Shipping{{ h.currency }} {{ num(h.shipping, 2) }}
Grand total + {{ svc.invoice()?.header?.currency }} {{ num(svc.invoice()?.grand_total ?? 0, 2) }} +
+
+ } }
diff --git a/docanalytics-web/src/app/features/files/file-details.component.ts b/docanalytics-web/src/app/features/files/file-details.component.ts index 1bb9e5e..b89c936 100644 --- a/docanalytics-web/src/app/features/files/file-details.component.ts +++ b/docanalytics-web/src/app/features/files/file-details.component.ts @@ -12,6 +12,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { map } from 'rxjs/operators'; import { FileDetailsService } from './file-details.service'; import { SiteContextService } from '../../core/services/site-context.service'; +import { AuthService } from '../../core/services/auth.service'; import { StatusBadgeComponent } from '../../shared/components/status-badge/status-badge.component'; import { InvoiceLineItem, StepHistoryItem } from './file-details.models'; @@ -20,7 +21,6 @@ import { InvoiceLineItem, StepHistoryItem } from './file-details.models'; standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [DatePipe, RouterLink, StatusBadgeComponent], - templateUrl: './file-details.component.html', styleUrl: './file-details.component.css', }) @@ -28,18 +28,25 @@ export class FileDetailsComponent { protected readonly svc = inject(FileDetailsService); private readonly route = inject(ActivatedRoute); private readonly site = inject(SiteContextService); + private readonly auth = inject(AuthService); - private readonly fileId = toSignal(this.route.paramMap.pipe(map((p) => p.get('fileId'))), { - initialValue: this.route.snapshot.paramMap.get('fileId'), - }); + private readonly fileId = toSignal( + this.route.paramMap.pipe(map((p) => p.get('fileId'))), + { initialValue: this.route.snapshot.paramMap.get('fileId') }, + ); protected readonly info = computed(() => this.svc.detail()?.file_info ?? null); protected readonly history = computed(() => this.svc.detail()?.history ?? []); protected readonly items = computed(() => this.svc.invoice()?.items ?? []); + /** True when the logged-in user is an Admin. */ + protected readonly isAdmin = computed( + () => this.auth.currentUser()?.role === 'Admin', + ); + constructor() { // reload on file switch (param-only nav) AND on site switch — both tracked, - // loads run in untracked so query reads inside don't re-fire the effect (R3 lesson). + // loads run in untracked so query reads inside don't re-fire the effect (R3 lesson) effect(() => { const id = this.fileId(); this.site.selectedSiteId(); diff --git a/docanalytics-web/src/app/features/files/file-details.models.ts b/docanalytics-web/src/app/features/files/file-details.models.ts index e54045b..f660096 100644 --- a/docanalytics-web/src/app/features/files/file-details.models.ts +++ b/docanalytics-web/src/app/features/files/file-details.models.ts @@ -55,3 +55,11 @@ export interface InvoiceDetail { grand_total: number; items: InvoiceLineItem[]; } + +// ── POST /api/v1/files/{id}/retry ── +export interface RetryFileResponse { + file_id: string; + new_status: string; + transaction_id: string; + transaction_state: string; +} diff --git a/docanalytics-web/src/app/features/files/file-details.service.ts b/docanalytics-web/src/app/features/files/file-details.service.ts index d870b5c..79c4efa 100644 --- a/docanalytics-web/src/app/features/files/file-details.service.ts +++ b/docanalytics-web/src/app/features/files/file-details.service.ts @@ -4,18 +4,17 @@ import { finalize } from 'rxjs'; import { environment } from '../../../environments/environment'; import { ApiResponse } from '../../core/models/api-response.model'; import { SKIP_ERROR_TOAST } from '../../core/interceptors/error.interceptor'; -import { FileDetail, InvoiceDetail } from './file-details.models'; +import { FileDetail, InvoiceDetail, RetryFileResponse } from './file-details.models'; @Injectable({ providedIn: 'root' }) export class FileDetailsService { private readonly http = inject(HttpClient); private readonly base = environment.apiBase; - // widgets render their own errors → opt out of the global toast private readonly silent = { context: new HttpContext().set(SKIP_ERROR_TOAST, true) }; private _fileId: string | null = null; - // ── details slice (FR-2.5) ── + // ── detail slice ── private _detail = signal(null); private _detailLoading = signal(false); private _detailError = signal(null); @@ -23,19 +22,25 @@ export class FileDetailsService { readonly detailLoading = this._detailLoading.asReadonly(); readonly detailError = this._detailError.asReadonly(); - // ── invoice line-items slice ── + // ── invoice slice ── private _invoice = signal(null); private _invoiceLoading = signal(false); private _invoiceError = signal(null); - private _hasInvoice = signal(true); // false on 404 (file has no invoice) + private _hasInvoice = signal(true); readonly invoice = this._invoice.asReadonly(); readonly invoiceLoading = this._invoiceLoading.asReadonly(); readonly invoiceError = this._invoiceError.asReadonly(); readonly hasInvoice = this._hasInvoice.asReadonly(); - /** Load both slices for a file. Called from the page effect on file/site switch. */ + // ── retry slice ── + private _retrying = signal(false); + private _retryError = signal(null); + readonly retrying = this._retrying.asReadonly(); + readonly retryError = this._retryError.asReadonly(); + load(fileId: string): void { this._fileId = fileId; + this._retryError.set(null); this.loadDetails(); this.loadLineItems(); } @@ -45,7 +50,8 @@ export class FileDetailsService { this._detailLoading.set(true); this._detailError.set(null); this.http - .get>(`${this.base}/files/${this._fileId}/details`, this.silent) + .get>( + `${this.base}/files/${this._fileId}/details`, this.silent) .pipe(finalize(() => this._detailLoading.set(false))) .subscribe({ next: (res) => this._detail.set(res.data), @@ -59,13 +65,13 @@ export class FileDetailsService { this._invoiceError.set(null); this._hasInvoice.set(true); this.http - .get>(`${this.base}/files/${this._fileId}/line-items`, this.silent) + .get>( + `${this.base}/files/${this._fileId}/line-items`, this.silent) .pipe(finalize(() => this._invoiceLoading.set(false))) .subscribe({ next: (res) => this._invoice.set(res.data), error: (err) => { if (err?.status === 404) { - // not an error — this file simply isn't an invoice this._hasInvoice.set(false); this._invoice.set(null); } else { @@ -75,6 +81,21 @@ export class FileDetailsService { }); } + /** Re-queues a failed file. Admin only — server enforces the role. */ + retryFile(): void { + if (!this._fileId) return; + this._retrying.set(true); + this._retryError.set(null); + this.http + .post>( + `${this.base}/files/${this._fileId}/retry`, {}, this.silent) + .pipe(finalize(() => this._retrying.set(false))) + .subscribe({ + next: () => this.loadDetails(), // refresh — badge will flip to Queued + error: (err) => this._retryError.set(this.msg(err, 'Retry failed. Please try again.')), + }); + } + downloadLogs(): void { if (!this._fileId) return; this.http @@ -102,13 +123,9 @@ export class FileDetailsService { if (!this._fileId) return; this.http .get>( - `${this.base}/files/${this._fileId}/download-url`, - this.silent, - ) + `${this.base}/files/${this._fileId}/download-url`, this.silent) .subscribe({ - next: (res) => { - if (res.data?.url) window.open(res.data.url, '_blank'); - }, + next: (res) => { if (res.data?.url) window.open(res.data.url, '_blank'); }, }); } @@ -119,6 +136,7 @@ export class FileDetailsService { this._invoice.set(null); this._invoiceError.set(null); this._hasInvoice.set(true); + this._retryError.set(null); } private msg(err: any, fallback: string): string { From 2ee258adf711f54ffa7a1e031a8dcb686a56057f Mon Sep 17 00:00:00 2001 From: Shubh Gupta Date: Tue, 28 Jul 2026 12:56:43 +0530 Subject: [PATCH 2/4] feat: editable error catalog CRUD endpoints (admin only) --- .../Controllers/ErrorCatalogController.cs | 69 ++++++++++++++++++ DocAnalytics.Api/Program.cs | 1 + .../Errors/ErrorServiceTests.cs | 15 ++-- .../Files/FileDetailsServiceTests.cs | 13 ++-- .../ErrorCatalog/ErrorCatalogDtos.cs | 27 +++++++ .../ErrorCatalogFeatureExtensions.cs | 13 ++++ .../ErrorCatalog/ErrorCatalogService.cs | 71 +++++++++++++++++++ .../ErrorCatalog/IErrorCatalogService.cs | 19 +++++ .../features/files/file-details.component.css | 23 ++++++ 9 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 DocAnalytics.Api/Controllers/ErrorCatalogController.cs create mode 100644 DocAnalytics.Service/ErrorCatalog/ErrorCatalogDtos.cs create mode 100644 DocAnalytics.Service/ErrorCatalog/ErrorCatalogFeatureExtensions.cs create mode 100644 DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs create mode 100644 DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs diff --git a/DocAnalytics.Api/Controllers/ErrorCatalogController.cs b/DocAnalytics.Api/Controllers/ErrorCatalogController.cs new file mode 100644 index 0000000..014040f --- /dev/null +++ b/DocAnalytics.Api/Controllers/ErrorCatalogController.cs @@ -0,0 +1,69 @@ +using DocAnalytics.Api.Common; +using DocAnalytics.Service.ErrorCatalog; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DocAnalytics.Api.Controllers; + +/// +/// Global error catalog management. +/// GET is open to all authenticated users; POST and PUT require Admin role. +/// +[ApiController] +[Authorize(Policy = "DataAccess")] +[Route("api/v1/error-catalog")] +public sealed class ErrorCatalogController : ControllerBase +{ + private readonly IErrorCatalogService _service; + + public ErrorCatalogController(IErrorCatalogService service) => _service = service; + + /// Returns all error catalog entries ordered by code. + // GET /api/v1/error-catalog + [HttpGet] + public async Task GetAll(CancellationToken ct) + { + var items = await _service.GetAllAsync(ct); + return Ok(ApiResponse>.Ok(items)); + } + + /// Creates a new error catalog entry. Admin only. + // POST /api/v1/error-catalog + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task Create( + [FromBody] CreateErrorCatalogDto dto, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(dto.ErrorCode) || string.IsNullOrWhiteSpace(dto.Description)) + return BadRequest(ApiResponse.Fail( + "VALIDATION", "ErrorCode and Description are required.")); + + var result = await _service.CreateAsync(dto, ct); + + if (result is null) + return Conflict(ApiResponse.Fail( + "DUPLICATE_CODE", $"Error code '{dto.ErrorCode.Trim().ToUpperInvariant()}' already exists.")); + + return Ok(ApiResponse.Ok(result)); + } + + /// Updates description and remediation of an existing entry. Admin only. + // PUT /api/v1/error-catalog/{id} + [HttpPut("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Update( + Guid id, [FromBody] UpdateErrorCatalogDto dto, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(dto.Description)) + return BadRequest(ApiResponse.Fail( + "VALIDATION", "Description is required.")); + + var result = await _service.UpdateAsync(id, dto, ct); + + if (result is null) + return NotFound(ApiResponse.Fail( + "NOT_FOUND", "Error catalog entry not found.")); + + return Ok(ApiResponse.Ok(result)); + } +} diff --git a/DocAnalytics.Api/Program.cs b/DocAnalytics.Api/Program.cs index 190f765..8d2c43c 100644 --- a/DocAnalytics.Api/Program.cs +++ b/DocAnalytics.Api/Program.cs @@ -64,6 +64,7 @@ builder.Services.AddDashboardFeature(); builder.Services.AddInvoiceFeature(); builder.Services.AddFileDetailsFeature(); +builder.Services.AddErrorCatalogFeature(); builder.Services.AddAnalyticsFeature(); builder.Services.AddErrorListFeature(); builder.Services.AddActivityLogFeature(); diff --git a/DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs b/DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs index ce20aa2..512adbc 100644 --- a/DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs +++ b/DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs @@ -1,4 +1,5 @@ using DocAnalytics.Domain.Entities; +using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog; using DocAnalytics.Service.Errors; using DocAnalytics.Service.Tests.Support; using MockQueryable.Moq; @@ -9,7 +10,7 @@ public class ErrorServiceTests { private static ErrorService BuildSut( IEnumerable files, IEnumerable steps, - IEnumerable txns, IEnumerable catalog) + IEnumerable txns, IEnumerable catalog) { var ctx = MockDb.Create(); ctx.Setup(c => c.Files).Returns(files.ToList().BuildMockDbSet().Object); @@ -31,7 +32,7 @@ public async Task GetErrorsAsync_returns_only_failed_steps_with_remediation() new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload", Status = "Success" }, }; var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } }; - var catalog = new[] { new ErrorCatalog { ErrorCode = "ERR1", RemediationMsg = "Retry the upload" } }; + var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "ERR1", RemediationMsg = "Retry the upload" } }; var sut = BuildSut(files, steps, txns, catalog); @@ -53,7 +54,7 @@ public async Task GetErrorsAsync_filters_by_step() }; var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } }; - var sut = BuildSut(files, steps, txns, Array.Empty()); + var sut = BuildSut(files, steps, txns, Array.Empty()); var result = await sut.GetErrorsAsync(new ErrorListQuery { Step = "Transform" }); @@ -68,7 +69,7 @@ public async Task GetErrorsAsync_filters_by_source() var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", TransactionId = txnId } }; var steps = new[] { new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1" } }; var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } }; - var sut = BuildSut(files, steps, txns, Array.Empty()); + var sut = BuildSut(files, steps, txns, Array.Empty()); Assert.Equal(1, (await sut.GetErrorsAsync(new ErrorListQuery { Source = "SAP" })).TotalCount); Assert.Equal(0, (await sut.GetErrorsAsync(new ErrorListQuery { Source = "CSV" })).TotalCount); @@ -85,7 +86,7 @@ public async Task GetErrorsAsync_filters_by_date_range() new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Load", Status = "Failed", ErrorCode = "E2", CompletedAt = new DateTime(2026,6,1,0,0,0,DateTimeKind.Utc) }, }; var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } }; - var sut = BuildSut(files, steps, txns, Array.Empty()); + var sut = BuildSut(files, steps, txns, Array.Empty()); var result = await sut.GetErrorsAsync(new ErrorListQuery { From = new DateTime(2026, 5, 1), To = new DateTime(2026, 7, 1) }); Assert.Equal(1, result.TotalCount); @@ -102,7 +103,7 @@ public async Task GetErrorsAsync_sorts_by_error_code_descending() new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Load", Status = "Failed", ErrorCode = "E9" }, }; var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } }; - var sut = BuildSut(files, steps, txns, Array.Empty()); + var sut = BuildSut(files, steps, txns, Array.Empty()); var result = await sut.GetErrorsAsync(new ErrorListQuery { SortBy = "error_code", SortDir = "desc" }); Assert.Equal("E9", result.Items[0].ErrorCode); @@ -119,7 +120,7 @@ public async Task GetErrorsForExportAsync_returns_all_matching_rows() new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Load", Status = "Failed", ErrorCode = "E2" }, }; var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } }; - var sut = BuildSut(files, steps, txns, Array.Empty()); + var sut = BuildSut(files, steps, txns, Array.Empty()); Assert.Equal(2, (await sut.GetErrorsForExportAsync(new ErrorListQuery())).Count); } diff --git a/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs b/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs index 0c5a988..d80dce0 100644 --- a/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs +++ b/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs @@ -1,11 +1,14 @@ using DocAnalytics.Domain.Common; using DocAnalytics.Domain.Entities; +using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog; using DocAnalytics.Service.Extraction; using DocAnalytics.Service.Files; using DocAnalytics.Service.Tests.Support; using MockQueryable.Moq; using Moq; + + namespace DocAnalytics.Service.Tests.Files; public class FileDetailsServiceTests @@ -13,7 +16,7 @@ public class FileDetailsServiceTests // ── helpers ──────────────────────────────────────────────────────────── private static Mock Ctx( - FileRecord[] files, FileStepHistory[] steps, ErrorCatalog[] catalog) + FileRecord[] files, FileStepHistory[] steps, ErrorCatalogEntry[] catalog) { var ctx = MockDb.Create(); ctx.Setup(c => c.Files).Returns(files.ToList().BuildMockDbSet().Object); @@ -36,7 +39,7 @@ public async Task GetFileDetailsAsync_returns_null_when_file_missing() { var sut = Svc(Ctx(Array.Empty(), Array.Empty(), - Array.Empty())); + Array.Empty())); Assert.Null(await sut.GetFileDetailsAsync(Guid.NewGuid())); } @@ -66,7 +69,7 @@ public async Task GetFileDetailsAsync_maps_history_with_remediation() StartedAt = DateTime.UtcNow.AddMinutes(-1), }, }; - var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } }; + var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "E1", RemediationMsg = "Fix it" } }; var dto = await Svc(Ctx(files, steps, catalog)).GetFileDetailsAsync(fileId); @@ -83,7 +86,7 @@ public async Task GetFileLogsAsync_returns_null_when_file_missing() { var sut = Svc(Ctx(Array.Empty(), Array.Empty(), - Array.Empty())); + Array.Empty())); Assert.Null(await sut.GetFileLogsAsync(Guid.NewGuid())); } @@ -108,7 +111,7 @@ public async Task GetFileLogsAsync_builds_downloadable_log() StartedAt = DateTime.UtcNow, }, }; - var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } }; + var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "E1", RemediationMsg = "Fix it" } }; var log = await Svc(Ctx(files, steps, catalog)).GetFileLogsAsync(fileId); diff --git a/DocAnalytics.Service/ErrorCatalog/ErrorCatalogDtos.cs b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogDtos.cs new file mode 100644 index 0000000..4ff8dbf --- /dev/null +++ b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogDtos.cs @@ -0,0 +1,27 @@ +namespace DocAnalytics.Service.ErrorCatalog; + +/// Read model for a single error catalog entry. +public sealed class ErrorCatalogDto +{ + public Guid Id { get; init; } + public string ErrorCode { get; init; } = null!; + public string Description { get; init; } = null!; + public string? RemediationMsg { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime UpdatedAt { get; init; } +} + +/// Body for POST /api/v1/error-catalog — create a new entry. +public sealed class CreateErrorCatalogDto +{ + public string ErrorCode { get; init; } = null!; + public string Description { get; init; } = null!; + public string? RemediationMsg { get; init; } +} + +/// Body for PUT /api/v1/error-catalog/{id} — update description + remediation only. +public sealed class UpdateErrorCatalogDto +{ + public string Description { get; init; } = null!; + public string? RemediationMsg { get; init; } +} diff --git a/DocAnalytics.Service/ErrorCatalog/ErrorCatalogFeatureExtensions.cs b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogFeatureExtensions.cs new file mode 100644 index 0000000..474f891 --- /dev/null +++ b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogFeatureExtensions.cs @@ -0,0 +1,13 @@ +using DocAnalytics.Service.ErrorCatalog; + +namespace Microsoft.Extensions.DependencyInjection; + +/// DI registration for the Error Catalog management feature. +public static class ErrorCatalogFeatureExtensions +{ + public static IServiceCollection AddErrorCatalogFeature(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs new file mode 100644 index 0000000..0eef0e5 --- /dev/null +++ b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs @@ -0,0 +1,71 @@ +using DocAnalytics.Data; +using DocAnalytics.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace DocAnalytics.Service.ErrorCatalog; + +/// Default implementation. +public sealed class ErrorCatalogService : IErrorCatalogService +{ + private readonly AppDbContext _db; + + public ErrorCatalogService(AppDbContext db) => _db = db; + + public async Task> GetAllAsync(CancellationToken ct = default) + => await _db.ErrorCatalog + .AsNoTracking() + .OrderBy(e => e.ErrorCode) + .Select(e => ToDto(e)) + .ToListAsync(ct); + + public async Task CreateAsync( + CreateErrorCatalogDto dto, CancellationToken ct = default) + { + var code = dto.ErrorCode.Trim().ToUpperInvariant(); + + // Unique error code guard (DB has a unique index, but we surface a clean error) + if (await _db.ErrorCatalog.AnyAsync(e => e.ErrorCode == code, ct)) + return null; // caller maps to 409 + + var now = DateTime.UtcNow; + var entry = new Domain.Entities.ErrorCatalog + { + Id = Guid.NewGuid(), + ErrorCode = code, + Description = dto.Description.Trim(), + RemediationMsg = dto.RemediationMsg?.Trim(), + CreatedAt = now, + UpdatedAt = now, + }; + + _db.ErrorCatalog.Add(entry); + await _db.SaveChangesAsync(ct); + return ToDto(entry); + } + + public async Task UpdateAsync( + Guid id, UpdateErrorCatalogDto dto, CancellationToken ct = default) + { + // FindAsync bypasses query filters → correct for a global (non-tenant) table + var entry = await _db.ErrorCatalog.FindAsync(new object[] { id }, ct); + if (entry is null) return null; // caller maps to 404 + + entry.Description = dto.Description.Trim(); + entry.RemediationMsg = dto.RemediationMsg?.Trim(); + entry.UpdatedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(ct); + return ToDto(entry); + } + + // ── projection helper ──────────────────────────────────────────────────── + private static ErrorCatalogDto ToDto(Domain.Entities.ErrorCatalog e) => new() + { + Id = e.Id, + ErrorCode = e.ErrorCode, + Description = e.Description, + RemediationMsg = e.RemediationMsg, + CreatedAt = e.CreatedAt, + UpdatedAt = e.UpdatedAt, + }; +} diff --git a/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs b/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs new file mode 100644 index 0000000..a95301d --- /dev/null +++ b/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs @@ -0,0 +1,19 @@ +namespace DocAnalytics.Service.ErrorCatalog; + +/// CRUD management for the global error catalog (Admin only for writes). +public interface IErrorCatalogService +{ + /// Returns every entry ordered by error code. + Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Creates a new entry. Returns null if the error code already exists (409 Conflict). + /// + Task CreateAsync(CreateErrorCatalogDto dto, CancellationToken ct = default); + + /// + /// Updates description + remediation of an existing entry. + /// Returns null if not found (404). + /// + Task UpdateAsync(Guid id, UpdateErrorCatalogDto dto, CancellationToken ct = default); +} diff --git a/docanalytics-web/src/app/features/files/file-details.component.css b/docanalytics-web/src/app/features/files/file-details.component.css index 9cc1200..4cf0dc3 100644 --- a/docanalytics-web/src/app/features/files/file-details.component.css +++ b/docanalytics-web/src/app/features/files/file-details.component.css @@ -252,3 +252,26 @@ .fd-btn-invoice:hover { background: color-mix(in srgb, var(--text-success, #27ae60) 12%, transparent); } + +/* Retry button — warning amber accent */ +.fd-btn-retry { + color: var(--status-warning, #d97706); + border-color: color-mix(in srgb, var(--status-warning, #d97706) 50%, transparent); +} + + .fd-btn-retry:hover:not(:disabled) { + background: color-mix(in srgb, var(--status-warning, #d97706) 12%, transparent); + } + + .fd-btn-retry:disabled { + opacity: 0.55; + cursor: not-allowed; + } + +/* Inline retry-error banner */ +.fd-retry-error { + color: var(--text-error, #c62828); + font-size: 0.85rem; + padding: 10px 14px; + margin: 0; +} From c84ddcd5b4fefdba843f9c39f71d0963675c31a2 Mon Sep 17 00:00:00 2001 From: Shubh Gupta Date: Tue, 28 Jul 2026 13:14:14 +0530 Subject: [PATCH 3/4] feat: editable error catalog with add, edit, delete (admin only) --- .../Controllers/ErrorCatalogController.cs | 12 ++ .../ErrorCatalog/ErrorCatalogService.cs | 10 + .../ErrorCatalog/IErrorCatalogService.cs | 4 + .../app/features/admin/admin.component.css | 19 ++ .../app/features/admin/admin.component.html | 200 +++++++++++------- .../src/app/features/admin/admin.component.ts | 78 ++++++- .../src/app/features/admin/admin.models.ts | 21 ++ .../src/app/features/admin/admin.service.ts | 74 ++++++- 8 files changed, 342 insertions(+), 76 deletions(-) diff --git a/DocAnalytics.Api/Controllers/ErrorCatalogController.cs b/DocAnalytics.Api/Controllers/ErrorCatalogController.cs index 014040f..3d04567 100644 --- a/DocAnalytics.Api/Controllers/ErrorCatalogController.cs +++ b/DocAnalytics.Api/Controllers/ErrorCatalogController.cs @@ -66,4 +66,16 @@ public async Task Update( return Ok(ApiResponse.Ok(result)); } + + // DELETE /api/v1/error-catalog/{id} + [HttpDelete("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var deleted = await _service.DeleteAsync(id, ct); + if (!deleted) + return NotFound(ApiResponse.Fail("NOT_FOUND", "Entry not found.")); + return Ok(ApiResponse.Ok(new { deleted = true })); + } + } diff --git a/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs index 0eef0e5..226971e 100644 --- a/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs +++ b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs @@ -58,6 +58,16 @@ public async Task> GetAllAsync(CancellationToken ct = defa return ToDto(entry); } + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var entry = await _db.ErrorCatalog.FindAsync(new object[] { id }, ct); + if (entry is null) return false; + _db.ErrorCatalog.Remove(entry); + await _db.SaveChangesAsync(ct); + return true; + } + + // ── projection helper ──────────────────────────────────────────────────── private static ErrorCatalogDto ToDto(Domain.Entities.ErrorCatalog e) => new() { diff --git a/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs b/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs index a95301d..c99a865 100644 --- a/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs +++ b/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs @@ -16,4 +16,8 @@ public interface IErrorCatalogService /// Returns null if not found (404). /// Task UpdateAsync(Guid id, UpdateErrorCatalogDto dto, CancellationToken ct = default); + + /// Deletes an entry. Returns false if not found. + Task DeleteAsync(Guid id, CancellationToken ct = default); + } diff --git a/docanalytics-web/src/app/features/admin/admin.component.css b/docanalytics-web/src/app/features/admin/admin.component.css index d8b64ae..1c654de 100644 --- a/docanalytics-web/src/app/features/admin/admin.component.css +++ b/docanalytics-web/src/app/features/admin/admin.component.css @@ -221,3 +221,22 @@ cursor: pointer; font-weight: 600; } + +/* Error catalog inline input */ +.ec-input { + flex: 1; + min-width: 120px; + padding: 6px 10px; + border: 1px solid var(--cool-gray, #ccc); + border-radius: 6px; + background: var(--white, #fff); + color: inherit; + font: inherit; +} + +code { + font-size: 12px; + background: color-mix(in srgb, var(--aveva-purple, #3d1152) 10%, transparent); + padding: 2px 6px; + border-radius: 4px; +} diff --git a/docanalytics-web/src/app/features/admin/admin.component.html b/docanalytics-web/src/app/features/admin/admin.component.html index cca433e..6de6fd9 100644 --- a/docanalytics-web/src/app/features/admin/admin.component.html +++ b/docanalytics-web/src/app/features/admin/admin.component.html @@ -7,13 +7,13 @@

User & Site Management

@if (notice()) { -

{{ notice() }}

+

{{ notice() }}

} @if (formError()) { -

{{ formError() }}

+

{{ formError() }}

} @if (svc.error()) { -

{{ svc.error() }}

+

{{ svc.error() }}

}
@@ -21,39 +21,39 @@

User & Site Management

Users

@if (svc.loading()) { -

Loading…

+

Loading…

} @else { - - - - - - - - - - - @for (u of svc.users(); track u.id) { - - - - - - - } - -
EmailSitesStatus
{{ u.email }} - @for (sid of u.site_ids; track sid) { - {{ siteName(sid) }} - } - {{ u.is_active ? 'Active' : 'Removed' }} - @if (u.is_active) { - - - } -
+ + + + + + + + + + + @for (u of svc.users(); track u.id) { + + + + + + + } + +
EmailSitesStatus
{{ u.email }} + @for (sid of u.site_ids; track sid) { + {{ siteName(sid) }} + } + {{ u.is_active ? 'Active' : 'Removed' }} + @if (u.is_active) { + + + } +
}

Add a user

@@ -67,16 +67,14 @@

Add a user

@for (s of svc.sitesList(); track s.id) { - @if (s.is_active) { - - } + @if (s.is_active) { + + } }
@@ -95,11 +93,11 @@

Sites

@for (s of svc.sitesList(); track s.id) { - - {{ s.name }} - {{ s.location ?? '—' }} - {{ s.is_active ? 'Active' : 'Removed' }} - + + {{ s.name }} + {{ s.location ?? '—' }} + {{ s.is_active ? 'Active' : 'Removed' }} + } @@ -115,28 +113,88 @@

Add a site

@if (editingUser(); as u) { -