diff --git a/DocAnalytics.Api/Controllers/ErrorCatalogController.cs b/DocAnalytics.Api/Controllers/ErrorCatalogController.cs new file mode 100644 index 0000000..3d04567 --- /dev/null +++ b/DocAnalytics.Api/Controllers/ErrorCatalogController.cs @@ -0,0 +1,81 @@ +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)); + } + + // 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.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.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..c28f294 100644 --- a/DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs +++ b/DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs @@ -2,6 +2,7 @@ using DocAnalytics.Service.Errors; using DocAnalytics.Service.Tests.Support; using MockQueryable.Moq; +using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog; namespace DocAnalytics.Service.Tests.Errors; @@ -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 55a265b..caa7c91 100644 --- a/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs +++ b/DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs @@ -1,15 +1,22 @@ +using DocAnalytics.Domain.Common; using DocAnalytics.Domain.Entities; +using DocAnalytics.Service.Extraction; using DocAnalytics.Service.Files; using DocAnalytics.Service.Tests.Support; using MockQueryable.Moq; using Moq; +using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog; + + namespace DocAnalytics.Service.Tests.Files; 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); @@ -18,10 +25,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 +47,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 catalog = new[] { new ErrorCatalogEntry { 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 +94,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 catalog = new[] { new ErrorCatalogEntry { 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/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..54651d6 --- /dev/null +++ b/DocAnalytics.Service/ErrorCatalog/ErrorCatalogService.cs @@ -0,0 +1,80 @@ +using DocAnalytics.Data; +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); + } + + 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() + { + 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..c99a865 --- /dev/null +++ b/DocAnalytics.Service/ErrorCatalog/IErrorCatalogService.cs @@ -0,0 +1,23 @@ +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); + + /// Deletes an entry. Returns false if not found. + Task DeleteAsync(Guid id, CancellationToken ct = default); + +} 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..a201d06 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 DocAnalytics.Service.Extraction; using Microsoft.EntityFrameworkCore; +using ActivityLogEntry = DocAnalytics.Domain.Entities.ActivityLog; // disambiguate from Service.ActivityLog namespace 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/angular.json b/docanalytics-web/angular.json index e20a93e..04d2e85 100644 --- a/docanalytics-web/angular.json +++ b/docanalytics-web/angular.json @@ -31,9 +31,7 @@ "input": "public" } ], - "styles": [ - "src/styles.css" - ] + "styles": ["src/styles.css"] }, "configurations": { "production": { @@ -94,7 +92,6 @@ } } } - } } } diff --git a/docanalytics-web/e2e/headers.spec.ts b/docanalytics-web/e2e/headers.spec.ts index 15e7233..ee06dee 100644 --- a/docanalytics-web/e2e/headers.spec.ts +++ b/docanalytics-web/e2e/headers.spec.ts @@ -10,9 +10,7 @@ test.describe('Auth-site interceptor headers', () => { // Reload and capture a data API call (exclude /auth/* which fires before site is set). const [req] = await Promise.all([ - page.waitForRequest( - (r) => r.url().includes('/api/v1/') && !r.url().includes('/auth/'), - ), + page.waitForRequest((r) => r.url().includes('/api/v1/') && !r.url().includes('/auth/')), page.reload(), ]); diff --git a/docanalytics-web/e2e/session.spec.ts b/docanalytics-web/e2e/session.spec.ts index 47aff5b..3e398dd 100644 --- a/docanalytics-web/e2e/session.spec.ts +++ b/docanalytics-web/e2e/session.spec.ts @@ -16,9 +16,7 @@ test.describe('Session rehydration', () => { // Reload wipes in-memory signals; the guard must call /auth/me to rehydrate. const [meResponse] = await Promise.all([ - page.waitForResponse( - (r) => r.url().includes('/auth/me') && r.request().method() === 'GET', - ), + page.waitForResponse((r) => r.url().includes('/auth/me') && r.request().method() === 'GET'), page.reload(), ]); expect(meResponse.ok()).toBeTruthy(); diff --git a/docanalytics-web/e2e/site-access.spec.ts b/docanalytics-web/e2e/site-access.spec.ts index be9c8d0..f558177 100644 --- a/docanalytics-web/e2e/site-access.spec.ts +++ b/docanalytics-web/e2e/site-access.spec.ts @@ -15,8 +15,7 @@ test.describe('Site-access guard', () => { // OFF the no-access site to a real dashboard (not the fake one, not /login). await page.waitForURL( (url) => - /\/site\/[^/]+\/dashboard$/.test(url.pathname) && - !url.pathname.includes(noAccessSite), + /\/site\/[^/]+\/dashboard$/.test(url.pathname) && !url.pathname.includes(noAccessSite), { timeout: 15_000 }, ); diff --git a/docanalytics-web/e2e/tsconfig.json b/docanalytics-web/e2e/tsconfig.json index 11bb328..d1ac030 100644 --- a/docanalytics-web/e2e/tsconfig.json +++ b/docanalytics-web/e2e/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": [ "node", "@playwright/test" ], + "types": ["node", "@playwright/test"], "noPropertyAccessFromIndexSignature": false, "outDir": "../out-tsc/e2e" }, - "include": [ "**/*.ts", "../playwright.config.ts" ] + "include": ["**/*.ts", "../playwright.config.ts"] } diff --git a/docanalytics-web/playwright.config.ts b/docanalytics-web/playwright.config.ts index 19147a9..3e60a03 100644 --- a/docanalytics-web/playwright.config.ts +++ b/docanalytics-web/playwright.config.ts @@ -6,21 +6,18 @@ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:4200'; export default defineConfig({ testDir: './e2e', fullyParallel: true, - forbidOnly: !!process.env.CI, // fail CI if a stray .only is committed + forbidOnly: !!process.env.CI, // fail CI if a stray .only is committed retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, timeout: 30_000, expect: { timeout: 5_000 }, - reporter: [ - ['html', { outputFolder: 'playwright-report', open: 'never' }], - ['list'], - ], + reporter: [['html', { outputFolder: 'playwright-report', open: 'never' }], ['list']], use: { baseURL: BASE_URL, - trace: 'on-first-retry', // trace on failure - screenshot: 'only-on-failure', // screenshot on failure + trace: 'on-first-retry', // trace on failure + screenshot: 'only-on-failure', // screenshot on failure video: 'retain-on-failure', }, 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..df2ff90 100644 --- a/docanalytics-web/src/app/features/admin/admin.component.html +++ b/docanalytics-web/src/app/features/admin/admin.component.html @@ -139,4 +139,80 @@ } + +
+

Error Catalog

+ + @if (svc.catalogLoading()) { +

Loading…

+ } @else if (svc.catalogError()) { +

{{ svc.catalogError() }}

+ } @else { + + + + + + + + + + + @for (e of svc.catalog(); track e.id) { + + @if (editingEntry()?.id === e.id) { + + + + + + } @else { + + + + + } + + } + +
CodeDescriptionRemediation
+ {{ e.error_code }} + + + + + + + + + {{ e.error_code }} + {{ e.description }}{{ e.remediation_msg ?? '—' }} + + +
+ } + + @if (catalogFormError()) { +

{{ catalogFormError() }}

+ } + +

Add entry

+
+ + + + +
+
diff --git a/docanalytics-web/src/app/features/admin/admin.component.ts b/docanalytics-web/src/app/features/admin/admin.component.ts index 9378ae6..5f00504 100644 --- a/docanalytics-web/src/app/features/admin/admin.component.ts +++ b/docanalytics-web/src/app/features/admin/admin.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AdminService } from './admin.service'; -import { AdminUser } from './admin.models'; +import { AdminUser, ErrorCatalogEntry } from './admin.models'; @Component({ selector: 'app-admin', @@ -14,24 +14,36 @@ import { AdminUser } from './admin.models'; export class AdminComponent { protected svc = inject(AdminService); - // create-user form + // ── create-user form ── protected uFirst = ''; protected uLast = ''; protected uSites = signal>(new Set()); - // create-site form + // ── create-site form ── protected sName = ''; protected sLocation = ''; - // per-user site editing + // ── per-user site editing ── protected editingUser = signal(null); protected editSites = signal>(new Set()); protected formError = signal(null); protected notice = signal(null); + // ── error catalog form ── + protected ecCode = ''; + protected ecDesc = ''; + protected ecRemediation = ''; + protected editingEntry = signal(null); + protected ecEditDesc = ''; + protected ecEditRemediation = ''; + protected catalogFormError = signal(null); + constructor() { this.svc.loadAll(); + this.svc.loadCatalog(); } + // ── user management ────────────────────────────────────────────────────── + protected toggleNewUserSite(id: string): void { const s = new Set(this.uSites()); s.has(id) ? s.delete(id) : s.add(id); @@ -104,4 +116,61 @@ export class AdminComponent { protected siteName(id: string): string { return this.svc.sitesList().find((s) => s.id === id)?.name ?? '?'; } + + // ── error catalog ───────────────────────────────────────────────────────── + + protected async addCatalogEntry(): Promise { + if (!this.ecCode.trim() || !this.ecDesc.trim()) { + this.catalogFormError.set('Error code and description are required.'); + return; + } + const err = await this.svc.createCatalogEntry({ + error_code: this.ecCode.trim(), + description: this.ecDesc.trim(), + remediation_msg: this.ecRemediation.trim() || null, + }); + this.catalogFormError.set(err); + if (!err) { + this.ecCode = ''; + this.ecDesc = ''; + this.ecRemediation = ''; + this.notice.set('Error catalog entry added.'); + } + } + + protected startEditEntry(e: ErrorCatalogEntry): void { + this.editingEntry.set(e); + this.ecEditDesc = e.description; + this.ecEditRemediation = e.remediation_msg ?? ''; + } + + protected async saveEditEntry(): Promise { + const e = this.editingEntry(); + if (!e) return; + if (!this.ecEditDesc.trim()) { + this.catalogFormError.set('Description is required.'); + return; + } + const err = await this.svc.updateCatalogEntry(e.id, { + description: this.ecEditDesc.trim(), + remediation_msg: this.ecEditRemediation.trim() || null, + }); + this.catalogFormError.set(err); + if (!err) { + this.editingEntry.set(null); + this.notice.set('Entry updated.'); + } + } + + protected cancelEditEntry(): void { + this.editingEntry.set(null); + this.catalogFormError.set(null); + } + + protected async removeCatalogEntry(e: ErrorCatalogEntry): Promise { + if (!confirm(`Delete error code "${e.error_code}"? This cannot be undone.`)) return; + const err = await this.svc.deleteCatalogEntry(e.id); + this.catalogFormError.set(err); + if (!err) this.notice.set('Entry deleted.'); + } } diff --git a/docanalytics-web/src/app/features/admin/admin.models.ts b/docanalytics-web/src/app/features/admin/admin.models.ts index 7ffce85..2bf1439 100644 --- a/docanalytics-web/src/app/features/admin/admin.models.ts +++ b/docanalytics-web/src/app/features/admin/admin.models.ts @@ -19,3 +19,24 @@ export interface AdminCreatedUser { email: string; credentials_emailed: boolean; } + +// ── Error Catalog ── +export interface ErrorCatalogEntry { + id: string; + error_code: string; + description: string; + remediation_msg: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateErrorCatalogPayload { + error_code: string; + description: string; + remediation_msg: string | null; +} + +export interface UpdateErrorCatalogPayload { + description: string; + remediation_msg: string | null; +} diff --git a/docanalytics-web/src/app/features/admin/admin.service.ts b/docanalytics-web/src/app/features/admin/admin.service.ts index 176609e..db1378e 100644 --- a/docanalytics-web/src/app/features/admin/admin.service.ts +++ b/docanalytics-web/src/app/features/admin/admin.service.ts @@ -3,18 +3,32 @@ import { Injectable, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; import { ApiResponse } from '../../core/models/api-response.model'; -import { AdminCreatedUser, AdminSite, AdminUser } from './admin.models'; +import { + AdminCreatedUser, + AdminSite, + AdminUser, + CreateErrorCatalogPayload, + ErrorCatalogEntry, + UpdateErrorCatalogPayload, +} from './admin.models'; @Injectable({ providedIn: 'root' }) export class AdminService { private http = inject(HttpClient); private base = `${environment.apiBase}/admin`; + private catalogBase = `${environment.apiBase}/error-catalog`; + // ── users + sites ── readonly users = signal([]); readonly sitesList = signal([]); readonly loading = signal(false); readonly error = signal(null); + // ── error catalog ── + readonly catalog = signal([]); + readonly catalogLoading = signal(false); + readonly catalogError = signal(null); + async loadAll(): Promise { this.loading.set(true); this.error.set(null); @@ -91,6 +105,56 @@ export class AdminService { } } + // ── Error Catalog ──────────────────────────────────────────────────────── + + async loadCatalog(): Promise { + this.catalogLoading.set(true); + this.catalogError.set(null); + try { + const res = await firstValueFrom( + this.http.get>(this.catalogBase), + ); + this.catalog.set(res.data ?? []); + } catch (e: any) { + this.catalogError.set(this.msg(e, 'Failed to load error catalog.')); + } finally { + this.catalogLoading.set(false); + } + } + + async createCatalogEntry(payload: CreateErrorCatalogPayload): Promise { + try { + await firstValueFrom( + this.http.post>(this.catalogBase, payload), + ); + await this.loadCatalog(); + return null; + } catch (e: any) { + return this.msg(e, 'Failed to create error catalog entry.'); + } + } + + async updateCatalogEntry(id: string, payload: UpdateErrorCatalogPayload): Promise { + try { + await firstValueFrom( + this.http.put>(`${this.catalogBase}/${id}`, payload), + ); + await this.loadCatalog(); + return null; + } catch (e: any) { + return this.msg(e, 'Failed to update error catalog entry.'); + } + } + async deleteCatalogEntry(id: string): Promise { + try { + await firstValueFrom(this.http.delete>(`${this.catalogBase}/${id}`)); + await this.loadCatalog(); + return null; + } catch (e: any) { + return this.msg(e, 'Failed to delete entry.'); + } + } + private msg(e: any, fallback: string): string { return e?.error?.error?.message ?? fallback; } 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..ccaa498 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; +} 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..755a56b 100644 --- a/docanalytics-web/src/app/features/files/file-details.component.html +++ b/docanalytics-web/src/app/features/files/file-details.component.html @@ -1,8 +1,6 @@
- ← Back to batch - @if (svc.detailLoading()) {
Loading file…
} @else if (svc.detailError()) { @@ -19,8 +17,22 @@

{{ fi.name }}

Current step: {{ fi.current_step }}

+
+ + + @if (isAdmin() && fi.current_status === 'Failed') { + + } +
- + + @if (svc.retryError()) { +

⚠ {{ svc.retryError() }}

+ } + +

Processing timeline

@if (history().length === 0) { @@ -63,7 +80,7 @@

Processing timeline

} - +

Invoice

@if (svc.invoiceLoading()) { @@ -76,7 +93,6 @@

Invoice

} @else { - @if (svc.invoice()?.header; as h) {
@@ -96,9 +112,8 @@

Invoice

} - @if (items().length === 0) { -
No line items — this file has no extracted invoice items.
+
No line items extracted.
} @else {
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..f323ef5 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,6 +28,7 @@ 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'), @@ -37,9 +38,12 @@ export class FileDetailsComponent { 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..77e98e9 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(); } @@ -65,7 +70,6 @@ export class FileDetailsService { 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 +79,24 @@ 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 @@ -119,6 +141,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 { diff --git a/docanalytics-web/tsconfig.app.json b/docanalytics-web/tsconfig.app.json index 264f459..a0dcc37 100644 --- a/docanalytics-web/tsconfig.app.json +++ b/docanalytics-web/tsconfig.app.json @@ -6,10 +6,6 @@ "outDir": "./out-tsc/app", "types": [] }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.spec.ts" - ] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] } diff --git a/docanalytics-web/tsconfig.spec.json b/docanalytics-web/tsconfig.spec.json index d383706..26230b0 100644 --- a/docanalytics-web/tsconfig.spec.json +++ b/docanalytics-web/tsconfig.spec.json @@ -4,12 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": [ - "vitest/globals" - ] + "types": ["vitest/globals"] }, - "include": [ - "src/**/*.d.ts", - "src/**/*.spec.ts" - ] + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] }