mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
fix(uplifts): open evidence by work-order access and keep every file
Auto-approved evidence was hidden because download checked the approval tier. Viewers of the work order can open it, and a request can store more than one scanned document.
This commit is contained in:
parent
31dd2d5dcd
commit
2f4b783615
20 changed files with 4960 additions and 72 deletions
|
|
@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
|
@ -36,7 +37,11 @@ public sealed class UpliftAdminApprovalTests
|
|||
new DispatchDataService(context),
|
||||
new NoopDocumentStorage(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()));
|
||||
Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()),
|
||||
accountResolver: new WorkOrderAccountResolver(
|
||||
new AccountDataService(context),
|
||||
new LocationDataService(context)),
|
||||
detailData: new WorkOrderDetailDataService(context));
|
||||
|
||||
private static async Task<Dispatch> SeedPendingAsync(ApplicationDbContext context, int requiredTier)
|
||||
{
|
||||
|
|
@ -158,7 +163,11 @@ public sealed class UpliftAdminApprovalTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None);
|
||||
var admin = UserWithRoles("Admin");
|
||||
((ClaimsIdentity)admin.Identity!).AddClaim(
|
||||
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(admin, 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.FileName.Should().Be("invoice.pdf");
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_NotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound());
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -335,7 +335,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_Locked_Returns423()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked());
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -351,7 +351,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_Available_ReturnsFile()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf"));
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -364,7 +364,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_Forbidden_ReturnsSanitized403()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy"));
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using SeaHaven.DataServices.Implementation;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
|
@ -128,13 +129,39 @@ public sealed class UpliftWorkflowTests
|
|||
Version = 1
|
||||
};
|
||||
|
||||
private static ClaimsPrincipal WorkOrderViewer(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "user-42"),
|
||||
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
||||
};
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal OtherAccountUser()
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "user-99"),
|
||||
new(SeaHavenClaimTypes.AccountId, "99"),
|
||||
new(ClaimTypes.Role, "Admin"),
|
||||
};
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private static UpliftService NewUpliftService(
|
||||
ApplicationDbContext context, IVendorDocumentStoragePort storage) =>
|
||||
new(new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
storage,
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()));
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
||||
accountResolver: new WorkOrderAccountResolver(
|
||||
new AccountDataService(context),
|
||||
new LocationDataService(context)),
|
||||
detailData: new WorkOrderDetailDataService(context));
|
||||
|
||||
private static VendorPortalService NewPortalService(
|
||||
ApplicationDbContext context,
|
||||
|
|
@ -910,7 +937,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, storage);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.ContentType.Should().Be("application/pdf");
|
||||
|
|
@ -918,30 +945,139 @@ public sealed class UpliftWorkflowTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ThrowsForbidden_WhenUserLacksRequiredTier()
|
||||
public async Task GetEvidenceForDownload_ReturnsOk_WhenTierZeroAndCallerCanSeeWorkOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var storage = new FakeDocumentStorage();
|
||||
await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 4000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 2,
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, storage);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Dispatcher"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.FileName.Should().Be("invoice.pdf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ThrowsForbidden_WhenWorkOrderIsOutsideAccount()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
workOrder.AccountId = 1;
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var act = () => service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var act = () => service.GetEvidenceForDownloadAsync(OtherAccountUser(), 1, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UpliftForbiddenException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsOk_ForASecondLinkedDocument()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var storage = new FakeDocumentStorage();
|
||||
await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||
await storage.SaveAsync(vendor.Id, dispatch.Id, "photo.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||
var quote = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
quote.Id = 1;
|
||||
var photo = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
photo.Id = 2;
|
||||
photo.OriginalFileName = "photo.jpg";
|
||||
photo.StoredFileName = "photo.bin";
|
||||
photo.ContentType = "image/jpeg";
|
||||
context.VendorCompletionDocuments.AddRange(quote, photo);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument
|
||||
{
|
||||
UpliftRequestId = 1,
|
||||
DocumentId = 2
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, storage);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(
|
||||
WorkOrderViewer("Dispatcher"),
|
||||
1,
|
||||
CancellationToken.None,
|
||||
documentId: 2);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.FileName.Should().Be("photo.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenDocumentIsNotLinked()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var linked = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
linked.Id = 1;
|
||||
var stranger = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
stranger.Id = 2;
|
||||
stranger.StoredFileName = "other.bin";
|
||||
context.VendorCompletionDocuments.AddRange(linked, stranger);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(
|
||||
WorkOrderViewer("Approver"),
|
||||
1,
|
||||
CancellationToken.None,
|
||||
documentId: 2);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence()
|
||||
{
|
||||
|
|
@ -961,7 +1097,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
|
@ -986,7 +1122,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked);
|
||||
}
|
||||
|
|
@ -1011,7 +1147,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
|
@ -1042,7 +1178,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,11 +167,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
// document id is accepted from the client. 404 covers missing request/evidence and
|
||||
// any non-UpliftEvidence document; 423 covers a scan that has not Passed.
|
||||
[HttpGet("{id:int}/evidence")]
|
||||
public async Task<IActionResult> DownloadEvidence(int id, CancellationToken cancellationToken = default)
|
||||
public async Task<IActionResult> DownloadEvidence(
|
||||
int id,
|
||||
[FromQuery] int? documentId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _upliftService.GetEvidenceForDownloadAsync(User, id, cancellationToken);
|
||||
var result = await _upliftService.GetEvidenceForDownloadAsync(
|
||||
User,
|
||||
id,
|
||||
cancellationToken,
|
||||
documentId);
|
||||
return result.Outcome switch
|
||||
{
|
||||
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,17 @@ namespace Data.SeaHavenIndustries
|
|||
.Property(u => u.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
builder.Entity<DispatchUpliftRequestDocument>(entity =>
|
||||
{
|
||||
entity.HasKey(link => new { link.UpliftRequestId, link.DocumentId });
|
||||
entity.HasOne(link => link.UpliftRequest)
|
||||
.WithMany()
|
||||
.HasForeignKey(link => link.UpliftRequestId);
|
||||
entity.HasOne(link => link.Document)
|
||||
.WithMany()
|
||||
.HasForeignKey(link => link.DocumentId);
|
||||
});
|
||||
|
||||
builder.Entity<WorkOrderFieldLock>()
|
||||
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
|
||||
.IsUnique();
|
||||
|
|
@ -397,6 +408,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
|
||||
public DbSet<DispatchSignoff> DispatchSignoffs { get; set; }
|
||||
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
||||
public DbSet<DispatchUpliftRequestDocument> DispatchUpliftRequestDocuments { get; set; }
|
||||
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
|
||||
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
|
||||
public DbSet<Service> Services { get; set; }
|
||||
|
|
|
|||
4392
Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs
generated
Normal file
4392
Data.SeaHavenIndustries/Migrations/20260928191151_SH396_UpliftEvidenceLinks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,50 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH396_UpliftEvidenceLinks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DispatchUpliftRequestDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
UpliftRequestId = table.Column<int>(type: "int", nullable: false),
|
||||
DocumentId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DispatchUpliftRequestDocuments", x => new { x.UpliftRequestId, x.DocumentId });
|
||||
table.ForeignKey(
|
||||
name: "FK_DispatchUpliftRequestDocuments_DispatchUpliftRequests_UpliftRequestId",
|
||||
column: x => x.UpliftRequestId,
|
||||
principalTable: "DispatchUpliftRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_DispatchUpliftRequestDocuments_VendorCompletionDocuments_DocumentId",
|
||||
column: x => x.DocumentId,
|
||||
principalTable: "VendorCompletionDocuments",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DispatchUpliftRequestDocuments_DocumentId",
|
||||
table: "DispatchUpliftRequestDocuments",
|
||||
column: "DocumentId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DispatchUpliftRequestDocuments");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1111,6 +1111,21 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("DispatchUpliftRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequestDocument", b =>
|
||||
{
|
||||
b.Property<int>("UpliftRequestId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("DocumentId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("UpliftRequestId", "DocumentId");
|
||||
|
||||
b.HasIndex("DocumentId");
|
||||
|
||||
b.ToTable("DispatchUpliftRequestDocuments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -3702,6 +3717,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("EvidenceDocument");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequestDocument", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "Document")
|
||||
.WithMany()
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.DispatchUpliftRequest", "UpliftRequest")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpliftRequestId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
|
||||
b.Navigation("UpliftRequest");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class DispatchUpliftRequestDocument
|
||||
{
|
||||
public int UpliftRequestId { get; set; }
|
||||
[ForeignKey(nameof(UpliftRequestId))]
|
||||
public virtual DispatchUpliftRequest? UpliftRequest { get; set; }
|
||||
|
||||
public int DocumentId { get; set; }
|
||||
[ForeignKey(nameof(DocumentId))]
|
||||
public virtual VendorCompletionDocument? Document { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -121,9 +121,16 @@ namespace Data.SeaHavenIndustries
|
|||
public string? NotificationError { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftEvidenceFileData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public class UpliftForWorkOrderData : UpliftForDispatchData
|
||||
{
|
||||
public string? CreatedByUserId { get; set; }
|
||||
public List<UpliftEvidenceFileData> Attachments { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpliftForDispatchData
|
||||
|
|
|
|||
|
|
@ -302,25 +302,100 @@ namespace SeaHaven.DataServices.Implementation
|
|||
// with the DispatchId equality filter enforces server-side request/document linkage:
|
||||
// a row is returned only when the document is the one linked to this exact request
|
||||
// and dispatch. Soft-deleted requests/documents never resolve.
|
||||
public async Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken)
|
||||
public async Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(
|
||||
int upliftRequestId,
|
||||
CancellationToken cancellationToken,
|
||||
int? documentId = null)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
where u.Id == upliftRequestId && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id
|
||||
where ev.DispatchId == u.DispatchId && (ev.IsDeleted == null || ev.IsDeleted == false)
|
||||
select new UpliftEvidenceDownloadData
|
||||
{
|
||||
Id = u.Id,
|
||||
DispatchId = u.DispatchId,
|
||||
VendorId = ev.VendorId,
|
||||
RequiredTier = u.RequiredTier,
|
||||
EvidenceDocumentId = u.EvidenceDocumentId,
|
||||
StoredFileName = ev.StoredFileName,
|
||||
OriginalFileName = ev.OriginalFileName,
|
||||
ContentType = ev.ContentType,
|
||||
Purpose = ev.Purpose,
|
||||
ScanStatus = ev.ScanStatus
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
var request = await _context.DispatchUpliftRequests
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
uplift => uplift.Id == upliftRequestId
|
||||
&& (uplift.IsDeleted == null || uplift.IsDeleted == false),
|
||||
cancellationToken);
|
||||
if (request == null)
|
||||
return null;
|
||||
|
||||
var resolvedDocumentId = documentId ?? request.EvidenceDocumentId;
|
||||
if (resolvedDocumentId == null)
|
||||
return null;
|
||||
|
||||
var linkedToRequest = resolvedDocumentId == request.EvidenceDocumentId
|
||||
|| await _context.DispatchUpliftRequestDocuments.AnyAsync(
|
||||
link => link.UpliftRequestId == request.Id && link.DocumentId == resolvedDocumentId,
|
||||
cancellationToken);
|
||||
if (!linkedToRequest)
|
||||
return null;
|
||||
|
||||
var document = await _context.VendorCompletionDocuments
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
candidate => candidate.Id == resolvedDocumentId
|
||||
&& candidate.DispatchId == request.DispatchId
|
||||
&& (candidate.IsDeleted == null || candidate.IsDeleted == false),
|
||||
cancellationToken);
|
||||
if (document == null)
|
||||
return null;
|
||||
|
||||
return new UpliftEvidenceDownloadData
|
||||
{
|
||||
Id = request.Id,
|
||||
DispatchId = request.DispatchId,
|
||||
VendorId = document.VendorId,
|
||||
RequiredTier = request.RequiredTier,
|
||||
EvidenceDocumentId = document.Id,
|
||||
StoredFileName = document.StoredFileName,
|
||||
OriginalFileName = document.OriginalFileName,
|
||||
ContentType = document.ContentType,
|
||||
Purpose = document.Purpose,
|
||||
ScanStatus = document.ScanStatus
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, IReadOnlyList<UpliftEvidenceFileData>>> GetEvidenceFilesAsync(
|
||||
IReadOnlyCollection<int> upliftRequestIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (upliftRequestIds.Count == 0)
|
||||
return new Dictionary<int, IReadOnlyList<UpliftEvidenceFileData>>();
|
||||
|
||||
var rows = await (
|
||||
from link in _context.DispatchUpliftRequestDocuments.AsNoTracking()
|
||||
where upliftRequestIds.Contains(link.UpliftRequestId)
|
||||
join document in _context.VendorCompletionDocuments.AsNoTracking()
|
||||
on link.DocumentId equals document.Id
|
||||
where document.IsDeleted == null || document.IsDeleted == false
|
||||
orderby link.DocumentId
|
||||
select new
|
||||
{
|
||||
link.UpliftRequestId,
|
||||
document.Id,
|
||||
document.OriginalFileName
|
||||
}).ToListAsync(cancellationToken);
|
||||
|
||||
return rows
|
||||
.GroupBy(row => row.UpliftRequestId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => (IReadOnlyList<UpliftEvidenceFileData>)group
|
||||
.Select(row => new UpliftEvidenceFileData
|
||||
{
|
||||
Id = row.Id,
|
||||
Name = row.OriginalFileName ?? ""
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList<int> documentIds)
|
||||
{
|
||||
foreach (var documentId in documentIds)
|
||||
{
|
||||
_context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument
|
||||
{
|
||||
UpliftRequest = request,
|
||||
DocumentId = documentId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
|
||||
// SH-101: server-side join of an uplift request with its linked evidence document.
|
||||
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
||||
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
||||
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(
|
||||
int upliftRequestId,
|
||||
CancellationToken cancellationToken,
|
||||
int? documentId = null);
|
||||
Task<IReadOnlyDictionary<int, IReadOnlyList<UpliftEvidenceFileData>>> GetEvidenceFilesAsync(
|
||||
IReadOnlyCollection<int> upliftRequestIds,
|
||||
CancellationToken cancellationToken);
|
||||
void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList<int> documentIds);
|
||||
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
// SH-393: the tracked dispatch a work-order uplift is written to, resolved through the
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ namespace SeaHaven.Services.DTOs
|
|||
public string DecisionNote { get; set; } = "";
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string EvidenceFileName { get; set; } = "";
|
||||
public List<WorkOrderUpliftAttachmentDto> Attachments { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WorkOrderUpliftAttachmentDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public class WorkOrderUpliftListDto
|
||||
|
|
@ -26,6 +33,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public decimal Amount { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public List<int>? EvidenceDocumentIds { get; set; }
|
||||
}
|
||||
|
||||
public class RevokeWorkOrderUpliftRequestDto
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ namespace SeaHaven.Services.Helpers
|
|||
DecisionNote = row.DecisionNote ?? "",
|
||||
EvidenceDocumentId = row.EvidenceDocumentId,
|
||||
EvidenceFileName = row.EvidenceFileName ?? "",
|
||||
Attachments = row.Attachments
|
||||
.Select(file => new WorkOrderUpliftAttachmentDto { Id = file.Id, Name = file.Name })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderUpliftEvidenceAssociation
|
||||
{
|
||||
public static IReadOnlyList<int> Normalize(int? single, IEnumerable<int>? many)
|
||||
{
|
||||
var ids = new List<int>();
|
||||
if (single is int one && one > 0)
|
||||
ids.Add(one);
|
||||
|
||||
if (many == null)
|
||||
return ids;
|
||||
|
||||
foreach (var id in many)
|
||||
{
|
||||
if (id > 0 && !ids.Contains(id))
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public static async Task RequirePassedAsync(
|
||||
IVendorDocumentDataService documents,
|
||||
IReadOnlyList<int> documentIds,
|
||||
int dispatchId,
|
||||
int vendorId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var documentId in documentIds)
|
||||
{
|
||||
var evidence = await documents.GetUpliftEvidenceAsync(
|
||||
documentId,
|
||||
dispatchId,
|
||||
vendorId,
|
||||
cancellationToken);
|
||||
if (evidence == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The selected evidence document is not available or has not passed scanning");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using SeaHaven.DataServices.Helpers;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
|
|
@ -17,6 +18,8 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
private readonly IWorkOrderUpliftService? _workOrderUpliftService;
|
||||
private readonly IWorkOrderAccountResolver? _accountResolver;
|
||||
private readonly IWorkOrderDetailDataService? _detailData;
|
||||
|
||||
public UpliftService(
|
||||
IUpliftDataService upliftData,
|
||||
|
|
@ -24,7 +27,9 @@ namespace SeaHaven.Services.Implementation
|
|||
IVendorDocumentStoragePort documentStorage,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ApprovalsOptions> approvalsOptions,
|
||||
IWorkOrderUpliftService? workOrderUpliftService = null)
|
||||
IWorkOrderUpliftService? workOrderUpliftService = null,
|
||||
IWorkOrderAccountResolver? accountResolver = null,
|
||||
IWorkOrderDetailDataService? detailData = null)
|
||||
{
|
||||
_upliftData = upliftData;
|
||||
_dispatchData = dispatchData;
|
||||
|
|
@ -32,6 +37,8 @@ namespace SeaHaven.Services.Implementation
|
|||
_timeProvider = timeProvider;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
_workOrderUpliftService = workOrderUpliftService;
|
||||
_accountResolver = accountResolver;
|
||||
_detailData = detailData;
|
||||
}
|
||||
|
||||
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
||||
|
|
@ -330,9 +337,13 @@ namespace SeaHaven.Services.Implementation
|
|||
// service resolves the document by the request's own linkage (no client-supplied
|
||||
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
||||
// completion documents resolve to NotFound. A scan that has not Passed is Locked.
|
||||
public async Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
|
||||
public async Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(
|
||||
ClaimsPrincipal user,
|
||||
int id,
|
||||
CancellationToken cancellationToken,
|
||||
int? documentId = null)
|
||||
{
|
||||
var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken);
|
||||
var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken, documentId);
|
||||
if (evidence == null
|
||||
|| evidence.EvidenceDocumentId == null
|
||||
|| !string.Equals(evidence.Purpose, VendorDocumentPurpose.UpliftEvidence, StringComparison.Ordinal))
|
||||
|
|
@ -340,9 +351,9 @@ namespace SeaHaven.Services.Implementation
|
|||
return UpliftEvidenceDownloadResultDTO.NotFound();
|
||||
}
|
||||
|
||||
if (!UserCanApprove(user, evidence.RequiredTier))
|
||||
if (!await CanReadWorkOrderEvidenceAsync(user, id, cancellationToken))
|
||||
{
|
||||
throw new UpliftForbiddenException($"Evidence access requires a Tier {evidence.RequiredTier} role");
|
||||
throw new UpliftForbiddenException("You are not authorized to view evidence for this uplift request");
|
||||
}
|
||||
|
||||
if (!string.Equals(evidence.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase))
|
||||
|
|
@ -356,6 +367,31 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
// Admin passes every permission check regardless of stored configuration,
|
||||
// so the tier-role lists only govern non-Admin approvers.
|
||||
private async Task<bool> CanReadWorkOrderEvidenceAsync(
|
||||
ClaimsPrincipal user,
|
||||
int upliftRequestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_accountResolver == null || _detailData == null)
|
||||
return false;
|
||||
|
||||
int? accountFilter;
|
||||
try
|
||||
{
|
||||
accountFilter = _accountResolver.ResolveAccountFilter(user);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftRequestId, cancellationToken);
|
||||
if (workOrderId == null)
|
||||
return false;
|
||||
|
||||
return await _detailData.ExistsAsync(workOrderId.Value, cancellationToken, accountFilter);
|
||||
}
|
||||
|
||||
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
||||
{
|
||||
if (user.IsInRole("Admin")) return true;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@ namespace SeaHaven.Services.Implementation
|
|||
return null;
|
||||
|
||||
var rows = await _upliftData.GetForWorkOrderAsync(workOrderId, cancellationToken);
|
||||
var files = await _upliftData.GetEvidenceFilesAsync(
|
||||
rows.Select(row => row.Id).ToArray(),
|
||||
cancellationToken);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (files.TryGetValue(row.Id, out var attachments))
|
||||
row.Attachments = attachments.ToList();
|
||||
}
|
||||
|
||||
return rows.Select(WorkOrderUpliftContractMapper.MapItem).ToList();
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +99,9 @@ namespace SeaHaven.Services.Implementation
|
|||
workOrderId,
|
||||
request.Amount,
|
||||
notes,
|
||||
request.EvidenceDocumentId,
|
||||
WorkOrderUpliftEvidenceAssociation.Normalize(
|
||||
request.EvidenceDocumentId,
|
||||
request.EvidenceDocumentIds),
|
||||
userId,
|
||||
requesterName,
|
||||
accountFilter,
|
||||
|
|
@ -132,7 +143,7 @@ namespace SeaHaven.Services.Implementation
|
|||
int workOrderId,
|
||||
decimal amount,
|
||||
string notes,
|
||||
int? evidenceDocumentId,
|
||||
IReadOnlyList<int> evidenceDocumentIds,
|
||||
string? userId,
|
||||
string requesterName,
|
||||
int? accountFilter,
|
||||
|
|
@ -173,7 +184,13 @@ namespace SeaHaven.Services.Implementation
|
|||
|| await _upliftData.HasActiveAsync(dispatch.Id, cancellationToken))
|
||||
throw new InvalidOperationException("An open uplift request already exists for this work order");
|
||||
|
||||
await RequirePassedEvidenceAsync(evidenceDocumentId, dispatch.Id, dispatch.VendorId, cancellationToken);
|
||||
await WorkOrderUpliftEvidenceAssociation.RequirePassedAsync(
|
||||
_documentData,
|
||||
evidenceDocumentIds,
|
||||
dispatch.Id,
|
||||
dispatch.VendorId,
|
||||
cancellationToken);
|
||||
var evidenceDocumentId = evidenceDocumentIds.Count == 0 ? (int?)null : evidenceDocumentIds[0];
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var current = dispatch.NTEAmount ?? 0m;
|
||||
|
|
@ -193,6 +210,7 @@ namespace SeaHaven.Services.Implementation
|
|||
amount,
|
||||
notes,
|
||||
evidenceDocumentId,
|
||||
evidenceDocumentIds,
|
||||
UpliftStatus.NoApprovalRequired,
|
||||
requiredTier: 0,
|
||||
expiresAt: null,
|
||||
|
|
@ -209,9 +227,10 @@ namespace SeaHaven.Services.Implementation
|
|||
requesterName,
|
||||
current,
|
||||
amount,
|
||||
notes,
|
||||
evidenceDocumentId,
|
||||
UpliftStatus.Pending,
|
||||
notes,
|
||||
evidenceDocumentId,
|
||||
evidenceDocumentIds,
|
||||
UpliftStatus.Pending,
|
||||
requiredTier: 1,
|
||||
now + _approvalsOptions.EffectiveExpiration,
|
||||
UpliftNotificationStatus.Pending,
|
||||
|
|
@ -380,6 +399,7 @@ namespace SeaHaven.Services.Implementation
|
|||
decimal amount,
|
||||
string notes,
|
||||
int? evidenceDocumentId,
|
||||
IReadOnlyList<int> evidenceDocumentIds,
|
||||
string status,
|
||||
int requiredTier,
|
||||
DateTime? expiresAt,
|
||||
|
|
@ -409,6 +429,7 @@ namespace SeaHaven.Services.Implementation
|
|||
dispatch.LastModificationTime = now;
|
||||
}
|
||||
await _upliftData.StageAsync(created, cancellationToken);
|
||||
_upliftData.StageEvidenceDocuments(created, evidenceDocumentIds);
|
||||
await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken);
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null);
|
||||
|
|
@ -443,27 +464,6 @@ namespace SeaHaven.Services.Implementation
|
|||
return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId);
|
||||
}
|
||||
|
||||
private async Task RequirePassedEvidenceAsync(
|
||||
int? evidenceDocumentId,
|
||||
int dispatchId,
|
||||
int vendorId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!evidenceDocumentId.HasValue)
|
||||
return;
|
||||
|
||||
var evidence = await _documentData.GetUpliftEvidenceAsync(
|
||||
evidenceDocumentId.Value,
|
||||
dispatchId,
|
||||
vendorId,
|
||||
cancellationToken);
|
||||
if (evidence == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The selected evidence document is not available or has not passed scanning");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ namespace SeaHaven.Services.Interfaces
|
|||
// SH-101: internal request-changes route (note + tier authorization + audit).
|
||||
Task<UpliftDecisionResultDTO> RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken);
|
||||
// SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request.
|
||||
Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
|
||||
Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(
|
||||
ClaimsPrincipal user,
|
||||
int id,
|
||||
CancellationToken cancellationToken,
|
||||
int? documentId = null);
|
||||
bool CanApprove(ClaimsPrincipal user, int tier);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -458,7 +458,9 @@ public class WorkOrderBoardCancelServiceTests
|
|||
public Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken, int? documentId = null) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyDictionary<int, IReadOnlyList<UpliftEvidenceFileData>>> GetEvidenceFilesAsync(IReadOnlyCollection<int> upliftRequestIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public void StageEvidenceDocuments(DispatchUpliftRequest request, IReadOnlyList<int> documentIds) => throw new NotSupportedException();
|
||||
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
|
|
|||
|
|
@ -904,6 +904,50 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
Assert.Empty(context.DispatchUpliftRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_MultipleEvidenceDocuments_LinksEachPassedFile()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||
var quote = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id);
|
||||
quote.OriginalFileName = "quote.pdf";
|
||||
var photo = new VendorCompletionDocument
|
||||
{
|
||||
Id = 23,
|
||||
VendorId = dispatch.VendorId,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "photo.jpg",
|
||||
StoredFileName = "stored.jpg",
|
||||
ContentType = "image/jpeg",
|
||||
SizeBytes = 4,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Processing",
|
||||
Purpose = "UpliftEvidence",
|
||||
Version = 1,
|
||||
};
|
||||
context.VendorCompletionDocuments.Add(photo);
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var created = await service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto
|
||||
{
|
||||
Amount = 400m,
|
||||
Notes = "Quote and photo",
|
||||
EvidenceDocumentIds = new List<int> { quote.Id, photo.Id },
|
||||
},
|
||||
Dispatcher(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(quote.Id, created!.EvidenceDocumentId);
|
||||
Assert.Equal(2, context.DispatchUpliftRequestDocuments.Count());
|
||||
var listed = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None);
|
||||
var names = Assert.Single(listed!).Attachments.Select(file => file.Name).OrderBy(name => name).ToArray();
|
||||
Assert.Equal(new[] { "photo.jpg", "quote.pdf" }, names);
|
||||
}
|
||||
|
||||
private static VendorCompletionDocument SeedEvidence(
|
||||
ApplicationDbContext context,
|
||||
int dispatchId,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue