Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor (provider vs client vs admin) from the query's PerformerId/ClientId and authorize accordingly — a non-party caller is forbidden. Either party may accept or reject. Acceptance may carry a PostIt signature, which is staged on the query's linked estimate (Estimate.CommandId == queryId) and sets the estimate's ProviderValidationDate / ClientValidationDate; a signature with no linked estimate, or a signature on a reject, returns 400. Admin bypasses authz but cannot sign. The shared signature-staging logic (find-or-add the Signature row by (EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted into EstimateSignaturePersister, and BillingController.Sign now calls it. The persister owns no disk I/O and no SaveChanges — the caller persists in its own transaction. Signature.Strokes is now the sole source of truth: drop the FilePath property, remove the on-disk EstimateSignatureFileHelper (and its tests), and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign flow is independent of Signature.FilePath and is unchanged. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
9aef10f281
commit
709846c58d
10 changed files with 5249 additions and 430 deletions
|
|
@ -1,9 +1,12 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Api.Test.Fixtures;
|
using Yavsc.Api.Test.Fixtures;
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
namespace Yavsc.Api.Test;
|
||||||
|
|
@ -34,21 +37,88 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
return http;
|
return http;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimal valid PostIt wire-format signature: one stroke of one
|
||||||
|
// point (k=1, then 2*k coords). See PostIt.Models.SignaturePadData.
|
||||||
|
private static readonly int[] SampleStrokes = { 1, 5000, 5000 };
|
||||||
|
|
||||||
|
private static HttpContent NoSignature() => JsonBody(new { });
|
||||||
|
|
||||||
|
private static HttpContent WithSignature() =>
|
||||||
|
JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 });
|
||||||
|
|
||||||
|
private static JsonContent JsonBody(object value) =>
|
||||||
|
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds an Rdv query with distinct parties (provider alice,
|
||||||
|
/// client bob) and, when <paramref name="withEstimate"/> is true,
|
||||||
|
/// an estimate linked to it via CommandId. Returns the query id
|
||||||
|
/// and (when seeded) the estimate id.
|
||||||
|
/// </summary>
|
||||||
|
private (long queryId, long estimateId) SeedQuery(bool withEstimate)
|
||||||
|
{
|
||||||
|
WorkflowHelpers.ConfigureBillingService();
|
||||||
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
|
||||||
|
|
||||||
|
var query = new RdvQuery
|
||||||
|
{
|
||||||
|
ActivityCode = "dev",
|
||||||
|
ClientId = "bob",
|
||||||
|
PerformerId = "alice",
|
||||||
|
Consent = true,
|
||||||
|
UserCreated = "alice",
|
||||||
|
UserModified = "alice",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
EventDate = DateTime.UtcNow.AddDays(1),
|
||||||
|
Location = location,
|
||||||
|
Reason = "Initial rendez-vous",
|
||||||
|
Status = Yavsc.QueryStatus.Inserted,
|
||||||
|
};
|
||||||
|
db.RdvQueries.Add(query);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
long estimateId = 0;
|
||||||
|
if (withEstimate)
|
||||||
|
{
|
||||||
|
var estimate = new Estimate
|
||||||
|
{
|
||||||
|
CommandId = query.Id,
|
||||||
|
ClientId = "bob",
|
||||||
|
OwnerId = "alice",
|
||||||
|
CommandType = BillingCodes.Rdv,
|
||||||
|
Title = "Devis prestation",
|
||||||
|
Description = "Devis de test",
|
||||||
|
AttachedFiles = new List<string>(),
|
||||||
|
AttachedGraphics = new List<string>(),
|
||||||
|
Bill = new List<CommandLine>
|
||||||
|
{
|
||||||
|
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
db.Estimates.Add(estimate);
|
||||||
|
db.SaveChanges();
|
||||||
|
estimateId = estimate.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (query.Id, estimateId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Front_accept_query_updates_status_without_server_error()
|
public async Task Front_accept_query_updates_status_without_server_error()
|
||||||
{
|
{
|
||||||
WorkflowHelpers.ConfigureBillingService();
|
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||||
_fixture.ResetAndSeedRdvQueryGraph();
|
|
||||||
|
|
||||||
long queryId;
|
|
||||||
using (var scope = _fixture.Services.CreateScope())
|
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
||||||
queryId = db.RdvQueries.Select(q => q.Id).Single();
|
|
||||||
}
|
|
||||||
|
|
||||||
using var http = NewClient();
|
using var http = NewClient();
|
||||||
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken);
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||||
|
NoSignature(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
@ -59,4 +129,118 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
|
|
||||||
Assert.Equal(QueryStatus.Accepted, updated.Status);
|
Assert.Equal(QueryStatus.Accepted, updated.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_accept_with_signature_sets_ProValidationDate_and_Pro_signature()
|
||||||
|
{
|
||||||
|
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "alice");
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||||
|
WithSignature(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
using var assertScope = _fixture.Services.CreateScope();
|
||||||
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
||||||
|
|
||||||
|
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
||||||
|
Assert.True(estimate.ProviderValidationDate != default);
|
||||||
|
Assert.True(estimate.ClientValidationDate == default);
|
||||||
|
|
||||||
|
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||||
|
Assert.Equal(SignatureType.Pro, signature.Type);
|
||||||
|
Assert.Equal("alice", signature.SignerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_accept_with_signature_sets_ClientValidationDate_and_Client_signature()
|
||||||
|
{
|
||||||
|
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "bob");
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||||
|
WithSignature(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
using var assertScope = _fixture.Services.CreateScope();
|
||||||
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
||||||
|
|
||||||
|
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
||||||
|
Assert.True(estimate.ClientValidationDate != default);
|
||||||
|
Assert.True(estimate.ProviderValidationDate == default);
|
||||||
|
|
||||||
|
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||||
|
Assert.Equal(SignatureType.Client, signature.Type);
|
||||||
|
Assert.Equal("bob", signature.SignerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_sets_status_Rejected()
|
||||||
|
{
|
||||||
|
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "alice");
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
|
||||||
|
NoSignature(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
using var assertScope = _fixture.Services.CreateScope();
|
||||||
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
Assert.Equal(QueryStatus.Rejected, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_party_actor_is_forbidden()
|
||||||
|
{
|
||||||
|
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "carol");
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||||
|
NoSignature(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Accept_with_signature_but_no_estimate_returns_400()
|
||||||
|
{
|
||||||
|
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "alice");
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||||
|
WithSignature(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_with_signature_returns_400()
|
||||||
|
{
|
||||||
|
var (queryId, _) = SeedQuery(withEstimate: true);
|
||||||
|
|
||||||
|
using var http = NewClient(subject: "alice");
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
|
||||||
|
WithSignature(),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -401,79 +401,12 @@ namespace Yavsc.ApiControllers
|
||||||
Strokes = body.Strokes,
|
Strokes = body.Strokes,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Disk write first: a disk failure shouldn't leave
|
// The Signature row is staged by the shared persister; we
|
||||||
// a Signature row pointing at a file that doesn't
|
// SaveChanges here so the signature write is one
|
||||||
// exist. The file helper throws on filesystem
|
// transaction. The Strokes column is the source of truth —
|
||||||
// problems and propagates here.
|
// there is no on-disk copy to roll back on failure.
|
||||||
FileReceivedInfo fi;
|
var signature = await EstimateSignaturePersister.StageAsync(
|
||||||
try
|
dbContext, id, type, userId, payload, token);
|
||||||
{
|
|
||||||
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload,
|
|
||||||
siteSettings, token);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "estimate {Id}: signature file write failed", id);
|
|
||||||
return BadRequest(new { Error = "file write failed", Detail = ex.Message });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find-or-add: the (EstimateId, Type) pair is
|
|
||||||
// unique, so a second POST for the same side of the
|
|
||||||
// estimate replaces the previous signature. EF
|
|
||||||
// translates this into a single UPDATE when the
|
|
||||||
// row exists and an INSERT otherwise; the unique
|
|
||||||
// index in ApplicationDbContext is the
|
|
||||||
// database-level guarantee that the contract
|
|
||||||
// holds if two requests race.
|
|
||||||
var signature = await dbContext.Signatures
|
|
||||||
.FirstOrDefaultAsync(s => s.EstimateId == id && s.Type == type, token);
|
|
||||||
|
|
||||||
if (signature is null)
|
|
||||||
{
|
|
||||||
signature = new Signature
|
|
||||||
{
|
|
||||||
EstimateId = id,
|
|
||||||
SignerId = userId,
|
|
||||||
Type = type,
|
|
||||||
};
|
|
||||||
dbContext.Signatures.Add(signature);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Roll the signer's quota back by the size of
|
|
||||||
// the file we're about to orphan: the old
|
|
||||||
// FilePath is no longer referenced once we
|
|
||||||
// overwrite FilePath below.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var orphan = new FileInfo(signature.FilePath);
|
|
||||||
if (orphan.Exists)
|
|
||||||
{
|
|
||||||
var signerForOrphan = await dbContext.Users
|
|
||||||
.FirstOrDefaultAsync(u => u.Id == userId, token);
|
|
||||||
if (signerForOrphan is not null)
|
|
||||||
signerForOrphan.DiskUsage =
|
|
||||||
Math.Max(0, signerForOrphan.DiskUsage - orphan.Length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* best effort — the file is being replaced anyway */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
signature.SignerId = userId;
|
|
||||||
signature.CoordinateMax = payload.CoordinateMax;
|
|
||||||
signature.Strokes = payload.Strokes;
|
|
||||||
signature.CapturedAtUtc = payload.CapturedAtUtc;
|
|
||||||
signature.FilePath = Path.Combine(fi.DestDir, fi.FileName);
|
|
||||||
|
|
||||||
// Bump the signer's quota. The Signature row's
|
|
||||||
// SignerId is the IdentityUser.Id (a string), so we
|
|
||||||
// look up by Id and not by username.
|
|
||||||
var signer = await dbContext.Users
|
|
||||||
.FirstOrDefaultAsync(u => u.Id == userId, token);
|
|
||||||
if (signer is not null)
|
|
||||||
{
|
|
||||||
signer.DiskUsage += new FileInfo(signature.FilePath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -482,10 +415,6 @@ namespace Yavsc.ApiControllers
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
|
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
|
||||||
// Best-effort rollback: remove the file we wrote
|
|
||||||
// so disk and db don't disagree.
|
|
||||||
try { System.IO.File.Delete(signature.FilePath); }
|
|
||||||
catch { /* swallow — the row will be re-orphaned, the user re-signs */ }
|
|
||||||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Abstract.Workflow;
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.ViewModels.FrontOffice;
|
using Yavsc.ViewModels.FrontOffice;
|
||||||
|
|
@ -13,13 +16,17 @@ namespace Yavsc.ApiControllers
|
||||||
public class FrontOfficeApiController : Controller
|
public class FrontOfficeApiController : Controller
|
||||||
{
|
{
|
||||||
ApplicationDbContext dbContext;
|
ApplicationDbContext dbContext;
|
||||||
|
private readonly IBillingService billing;
|
||||||
|
private readonly ILogger logger;
|
||||||
|
|
||||||
private IBillingService billing;
|
public FrontOfficeApiController(
|
||||||
|
ApplicationDbContext context,
|
||||||
public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null)
|
ILoggerFactory loggerFactory,
|
||||||
|
IBillingService billing = null)
|
||||||
{
|
{
|
||||||
dbContext = context;
|
dbContext = context;
|
||||||
this.billing = billing ?? new BillingService(context);
|
this.billing = billing ?? new BillingService(context);
|
||||||
|
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("profiles/{actCode}")]
|
[HttpGet("profiles/{actCode}")]
|
||||||
|
|
@ -28,29 +35,183 @@ namespace Yavsc.ApiControllers
|
||||||
return await dbContext.ListPerformersAsync(billing, actCode);
|
return await dbContext.ListPerformersAsync(billing, actCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("query/reject")]
|
// POST: api/front/query/accept
|
||||||
public IActionResult RejectQuery(string billingCode, long queryId)
|
//
|
||||||
{
|
// Either party of the query may accept: the provider
|
||||||
if (billingCode == null) return BadRequest("billingCode");
|
// (PerformerId) accepts the client's request, or the client
|
||||||
if (queryId == 0) return BadRequest("queryId");
|
// (ClientId) accepts the provider's estimate. An optional
|
||||||
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
// signature (the PostIt wire format) may be sent along; it is
|
||||||
if (billing == null) return BadRequest();
|
// persisted on the estimate linked to the query
|
||||||
|
// (Estimate.CommandId == queryId), and the matching validation
|
||||||
billing.Status = QueryStatus.Rejected;
|
// date is stamped — ProviderValidationDate when the provider
|
||||||
dbContext.SaveChanges(User.GetUserId());
|
// accepts, ClientValidationDate when the client accepts.
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("query/accept")]
|
[HttpPost("query/accept")]
|
||||||
public IActionResult AcceptQuery(string billingCode, long queryId)
|
public async Task<IActionResult> AcceptQuery(
|
||||||
|
string billingCode,
|
||||||
|
long queryId,
|
||||||
|
[FromBody] QueryAcceptanceRequest? body,
|
||||||
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
if (billingCode == null) return BadRequest("billingCode");
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
if (queryId == 0) return BadRequest("queryId");
|
if (error is not null) return error;
|
||||||
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
if (billing == null) return BadRequest();
|
|
||||||
billing.Status = QueryStatus.Accepted;
|
Signature? signature = null;
|
||||||
dbContext.SaveChanges(User.GetUserId());
|
if (HasSignature(body))
|
||||||
return Ok();
|
{
|
||||||
|
// Admins bypass authorization but are neither party of
|
||||||
|
// the estimate, so they cannot sign.
|
||||||
|
if (role == ActorRole.Admin)
|
||||||
|
return BadRequest(new { Error = "admin cannot sign on behalf of a party" });
|
||||||
|
|
||||||
|
var estimate = await dbContext.Estimates
|
||||||
|
.Where(e => e.CommandId == queryId)
|
||||||
|
.OrderByDescending(e => e.Id)
|
||||||
|
.FirstOrDefaultAsync(token);
|
||||||
|
if (estimate is null)
|
||||||
|
return BadRequest(new { Error = "no estimate linked to this query" });
|
||||||
|
|
||||||
|
var type = role == ActorRole.Provider
|
||||||
|
? SignatureType.Pro
|
||||||
|
: SignatureType.Client;
|
||||||
|
|
||||||
|
var payload = new SignaturePadPayload
|
||||||
|
{
|
||||||
|
CoordinateMax = body!.CoordinateMax,
|
||||||
|
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
|
||||||
|
Strokes = body.Strokes!,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
signature = await EstimateSignaturePersister.StageAsync(
|
||||||
|
dbContext, estimate.Id, type, uid, payload, token);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "query {QueryId}: signature stage failed", queryId);
|
||||||
|
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role == ActorRole.Provider)
|
||||||
|
estimate.ProviderValidationDate = DateTime.UtcNow;
|
||||||
|
else
|
||||||
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Status = QueryStatus.Accepted;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "query {QueryId}: accept db write failed", queryId);
|
||||||
|
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
queryId,
|
||||||
|
status = query.Status.ToString(),
|
||||||
|
signature = signature is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
id = signature.Id,
|
||||||
|
estimateId = signature.EstimateId,
|
||||||
|
type = signature.Type.ToString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/front/query/reject
|
||||||
|
//
|
||||||
|
// Either party may reject. A signature is not meaningful on a
|
||||||
|
// rejection, so one is refused with 400.
|
||||||
|
[HttpPost("query/reject")]
|
||||||
|
public async Task<IActionResult> RejectQuery(
|
||||||
|
string billingCode,
|
||||||
|
long queryId,
|
||||||
|
[FromBody] QueryAcceptanceRequest? body,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
if (HasSignature(body))
|
||||||
|
return BadRequest(new { Error = "signature not allowed on reject" });
|
||||||
|
|
||||||
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
|
if (error is not null) return error;
|
||||||
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
|
query.Status = QueryStatus.Rejected;
|
||||||
|
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
queryId,
|
||||||
|
status = query.Status.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the query and checks that the caller is a party to it
|
||||||
|
// (provider or client) or an admin. Returns the role the caller
|
||||||
|
// is acting in; Admin is only returned when the caller is
|
||||||
|
// neither party but is an admin.
|
||||||
|
private async Task<(IQuery? Query, ActorRole Role, IActionResult? Error)>
|
||||||
|
ResolveAndAuthorizeAsync(string billingCode, long queryId, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
return (null, ActorRole.None, BadRequest("billingCode"));
|
||||||
|
if (queryId == 0)
|
||||||
|
return (null, ActorRole.None, BadRequest("queryId"));
|
||||||
|
|
||||||
|
var query = await billing.GetBillAsync(billingCode, queryId);
|
||||||
|
if (query is null)
|
||||||
|
return (null, ActorRole.None, BadRequest());
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
bool isProvider = query.PerformerId == uid;
|
||||||
|
bool isClient = query.ClientId == uid;
|
||||||
|
|
||||||
|
if (isProvider) return (query, ActorRole.Provider, null);
|
||||||
|
if (isClient) return (query, ActorRole.Client, null);
|
||||||
|
|
||||||
|
if (User.IsInRole(Constants.AdminGroupName))
|
||||||
|
return (query, ActorRole.Admin, null);
|
||||||
|
|
||||||
|
return (null, ActorRole.None, Forbid());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasSignature(QueryAcceptanceRequest? body)
|
||||||
|
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum ActorRole { None, Provider, Client, Admin }
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Yavsc.ViewModels.FrontOffice
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Body of <c>POST /api/v1/front/query/accept</c> and
|
||||||
|
/// <c>.../reject</c>. The <see cref="Strokes"/> field is optional:
|
||||||
|
/// when present (non-empty) on accept, it is the PostIt wire-format
|
||||||
|
/// signature captured with the acceptance. All fields are optional
|
||||||
|
/// so a caller may post <c>{}</c> to accept/reject without a
|
||||||
|
/// signature.
|
||||||
|
/// </summary>
|
||||||
|
public class QueryAcceptanceRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-format strokes. See
|
||||||
|
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
|
||||||
|
/// no signature.
|
||||||
|
/// </summary>
|
||||||
|
public int[]? Strokes { get; set; }
|
||||||
|
|
||||||
|
public int CoordinateMax { get; set; } = 10_000;
|
||||||
|
|
||||||
|
public DateTime? CapturedAtUtc { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Xunit;
|
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
using Yavsc.Server.Helpers;
|
|
||||||
using Yavsc.Server.Models.FileSystem;
|
|
||||||
|
|
||||||
namespace Yavsc.Org.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests for the <see cref="EstimateSignatureFileHelper"/> static
|
|
||||||
/// helper. Scope is intentionally narrow: the file-naming format,
|
|
||||||
/// the strokes counter, and the on-disk write path. The controller
|
|
||||||
/// (authz, db persistence, signalR notification) is out of scope
|
|
||||||
/// for this commit and will get a dedicated integration test once
|
|
||||||
/// the Yavsc.Api test project is set up.
|
|
||||||
/// </summary>
|
|
||||||
public class EstimateSignatureFileHelperTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly string _tempRoot;
|
|
||||||
private SiteSettings siteSettings;
|
|
||||||
|
|
||||||
public EstimateSignatureFileHelperTests()
|
|
||||||
{
|
|
||||||
// UserFilesDirName is a process-wide static; we redirect
|
|
||||||
// it to a per-test temp dir so concurrent tests don't
|
|
||||||
// collide and the host filesystem is not littered.
|
|
||||||
_tempRoot = Path.Combine(
|
|
||||||
Path.GetTempPath(),
|
|
||||||
"yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
|
|
||||||
Directory.CreateDirectory(_tempRoot);
|
|
||||||
this.siteSettings = new SiteSettings { Blog = _tempRoot };
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
try { Directory.Delete(_tempRoot, recursive: true); }
|
|
||||||
catch { /* best effort — the OS will clean Temp eventually */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
|
|
||||||
{
|
|
||||||
var name = EstimateSignatureFileHelper.FileNameFormat(
|
|
||||||
SignatureType.Pro, 42, 638_000_000_000_000_000L);
|
|
||||||
Assert.Equal("sign-pro-42-638000000000000000.json", name);
|
|
||||||
|
|
||||||
var cli = EstimateSignatureFileHelper.FileNameFormat(
|
|
||||||
SignatureType.Client, 7, 1L);
|
|
||||||
Assert.Equal("sign-client-7-1.json", cli);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(new int[] { }, 0)]
|
|
||||||
[InlineData(new[] { 1, 100, 200 }, 1)]
|
|
||||||
[InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
|
|
||||||
[InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
|
|
||||||
[InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
|
|
||||||
public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
|
|
||||||
{
|
|
||||||
// We don't read the count back from the helper (it's a
|
|
||||||
// private method), but the JSON envelope must reflect
|
|
||||||
// it; this verifies the public behaviour end-to-end.
|
|
||||||
_ = expectedStrokeCount;
|
|
||||||
// Arrange
|
|
||||||
var user = MakeUser("alice");
|
|
||||||
var payload = new SignaturePadPayload
|
|
||||||
{
|
|
||||||
CoordinateMax = 10_000,
|
|
||||||
CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
|
|
||||||
Strokes = strokes,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var fi = Run(user, 123L, SignatureType.Pro, payload, this.siteSettings);
|
|
||||||
|
|
||||||
// Assert: file exists, sits under the user's root, and
|
|
||||||
// parses as a yavsc.signature/v1 envelope.
|
|
||||||
var fullPath = Path.Combine(fi.DestDir, fi.FileName);
|
|
||||||
Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
|
|
||||||
var root = doc.RootElement;
|
|
||||||
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
|
|
||||||
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
|
|
||||||
Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
|
|
||||||
Assert.Equal("Pro", root.GetProperty("type").GetString());
|
|
||||||
Assert.Equal("alice", root.GetProperty("signerName").GetString());
|
|
||||||
Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
|
|
||||||
{
|
|
||||||
var user = MakeUser("bob");
|
|
||||||
// Capture TestContext.Current.CancellationToken outside the
|
|
||||||
// lambda so xUnit1051 sees a real CancellationToken argument
|
|
||||||
// (the lambda body runs on a different stack frame).
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
|
||||||
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
|
||||||
user, 1L, SignatureType.Pro, payload: null!,
|
|
||||||
siteSettings, token: ct));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
|
|
||||||
{
|
|
||||||
var user = MakeUser("bob");
|
|
||||||
var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
|
||||||
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
|
||||||
user, 0L, SignatureType.Pro, payload, this.siteSettings, token: ct));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ----------------------------------------------------
|
|
||||||
|
|
||||||
private static FileReceivedInfo Run(
|
|
||||||
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload, SiteSettings siteSettings)
|
|
||||||
{
|
|
||||||
// The helper is async; tests that don't care about the
|
|
||||||
// result can call it sync via .GetAwaiter().GetResult()
|
|
||||||
// because we know it never throws in the happy path.
|
|
||||||
return EstimateSignatureFileHelper
|
|
||||||
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, siteSettings, CancellationToken.None)
|
|
||||||
.GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ClaimsPrincipal MakeUser(string username)
|
|
||||||
{
|
|
||||||
return new ClaimsPrincipal(new ClaimsIdentity(
|
|
||||||
new[] { new Claim(ClaimTypes.Name, username) },
|
|
||||||
authenticationType: "test"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4713
src/Yavsc.Org/Migrations/20260921145454_DropSignatureFilePath.Designer.cs
generated
Normal file
4713
src/Yavsc.Org/Migrations/20260921145454_DropSignatureFilePath.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Yavsc.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DropSignatureFilePath : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FilePath",
|
||||||
|
table: "Signatures");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FilePath",
|
||||||
|
table: "Signatures",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ namespace Yavsc.Migrations
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
.HasAnnotation("ProductVersion", "10.0.12")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
@ -1521,10 +1521,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<long>("EstimateId")
|
b.Property<long>("EstimateId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("FilePath")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("SignerId")
|
b.Property<string>("SignerId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
@ -3959,8 +3955,8 @@ namespace Yavsc.Migrations
|
||||||
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
|
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
|
||||||
.WithMany()
|
.WithOne("Publication")
|
||||||
.HasForeignKey("PostId")
|
.HasForeignKey("Yavsc.Models.BlogSpotPublication", "PostId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
|
@ -4614,6 +4610,9 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.Navigation("Comments");
|
b.Navigation("Comments");
|
||||||
|
|
||||||
|
b.Navigation("Publication")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Tags");
|
b.Navigation("Tags");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
#nullable enable annotations
|
|
||||||
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
using Yavsc.Server.Models.FileSystem;
|
|
||||||
namespace Yavsc.Server.Helpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filesystem counterpart of <see cref="Signature"/>: writes
|
|
||||||
/// the wire-format JSON payload next to the user's other files,
|
|
||||||
/// under <c>signatures/</c>, and updates the user's disk quota.
|
|
||||||
///
|
|
||||||
/// This is the JSON counterpart of the legacy
|
|
||||||
/// <c>ReceiveProSignatureAsync</c> method, which stored
|
|
||||||
/// <c>sign-{billingCode}-{signType}-{estimateId}.png</c> blobs.
|
|
||||||
/// We don't reuse that helper because (a) the wire format is no
|
|
||||||
/// longer a binary image, (b) there's no <c>billingCode</c> on
|
|
||||||
/// a freshly signed estimate in our model, and (c) the legacy
|
|
||||||
/// helper takes an <see cref="IFormFile"/> whereas our pipeline
|
|
||||||
/// decodes a JSON body upstream of the controller and passes
|
|
||||||
/// <see cref="SignaturePadPayload"/> in directly.
|
|
||||||
/// </summary>
|
|
||||||
public static class EstimateSignatureFileHelper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sub-directory under the user's root where signature
|
|
||||||
/// payloads live. Kept short to leave room in PATH_MAX on
|
|
||||||
/// legacy filesystems; the rest of the filename is
|
|
||||||
/// <c>sign-{type}-{estimateId}-{utcTicks}.json</c>.
|
|
||||||
/// </summary>
|
|
||||||
public const string SignaturesSubdir = "signatures";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Format string for signature file names. Public so the
|
|
||||||
/// migration and the admin tools can list by pattern.
|
|
||||||
/// </summary>
|
|
||||||
public static string FileNameFormat(SignatureType type, long estimateId, long utcTicks)
|
|
||||||
=> $"sign-{type.ToString().ToLowerInvariant()}-{estimateId}-{utcTicks}.json";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persist a signature wire payload to disk. Returns the
|
|
||||||
/// file info (relative path under the user's root) suitable
|
|
||||||
/// for storing in <see cref="Signature.FilePath"/>; the
|
|
||||||
/// caller is responsible for the database write.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">Signed-in user. Their
|
|
||||||
/// <c>Identity.Name</c> locates the disk root via
|
|
||||||
/// <see cref="AbstractFileSystemHelpers.UserFilesDirName"/>.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="estimateId">Estimate this signature
|
|
||||||
/// attaches to. Used in the file name for human inspection
|
|
||||||
/// and to support multiple versions over time.</param>
|
|
||||||
/// <param name="type">Provider or client signature.</param>
|
|
||||||
/// <param name="payload">Decoded wire payload (strokes +
|
|
||||||
/// coordinateMax + capturedAtUtc). Already validated
|
|
||||||
/// upstream.</param>
|
|
||||||
/// <param name="token">Cancellation token forwarded to
|
|
||||||
/// the file write.</param>
|
|
||||||
public static async Task<FileReceivedInfo> ReceiveEstimateSignatureAsync(
|
|
||||||
this ClaimsPrincipal user,
|
|
||||||
long estimateId,
|
|
||||||
SignatureType type,
|
|
||||||
SignaturePadPayload payload,
|
|
||||||
SiteSettings siteSettings,
|
|
||||||
CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (user is null) throw new ArgumentNullException(nameof(user));
|
|
||||||
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
|
||||||
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
|
|
||||||
|
|
||||||
// Ensure the user has a /signatures/ sub-directory we can
|
|
||||||
// write to. EnsureDestinationDirectory throws on invalid
|
|
||||||
// paths and creates the directory on the way; the
|
|
||||||
// SignaturesSubdir constant is a server-controlled value
|
|
||||||
// (not user-derived), so we skip the IsValidYavscPath
|
|
||||||
// check that ReceiveUserFile performs on user-supplied
|
|
||||||
// subpaths.
|
|
||||||
var root = user.EnsureDestinationDirectory(SignaturesSubdir, siteSettings);
|
|
||||||
|
|
||||||
var fileName = FileNameFormat(type, estimateId, DateTime.UtcNow.Ticks);
|
|
||||||
var fullPath = Path.Combine(root, fileName);
|
|
||||||
|
|
||||||
var envelope = new
|
|
||||||
{
|
|
||||||
format = "yavsc.signature/v1",
|
|
||||||
coordinateMax = payload.CoordinateMax,
|
|
||||||
capturedAtUtc = payload.CapturedAtUtc,
|
|
||||||
estimateId,
|
|
||||||
type = type.ToString(),
|
|
||||||
// Identity.Name is the username; we keep the wire
|
|
||||||
// payload keyed on the username rather than the
|
|
||||||
// numeric/guid Id so disk-side human inspection
|
|
||||||
// (e.g. cat sign-pro-1234-...json) is self-evident.
|
|
||||||
signerName = user.Identity?.Name,
|
|
||||||
strokes = payload.Strokes,
|
|
||||||
strokeCount = CountStrokes(payload.Strokes),
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8, token).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Quota update is the controller's responsibility: the
|
|
||||||
// helper has no DbContext access, and a ClaimsPrincipal
|
|
||||||
// is not an ApplicationUser. The controller looks up
|
|
||||||
// the user by Identity.Name and bumps DiskUsage after
|
|
||||||
// a successful database write.
|
|
||||||
|
|
||||||
return new FileReceivedInfo(root, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CountStrokes(int[] strokes)
|
|
||||||
{
|
|
||||||
int n = 0;
|
|
||||||
for (int i = 0; i < strokes.Length;)
|
|
||||||
{
|
|
||||||
int k = strokes[i];
|
|
||||||
if (k <= 0) break;
|
|
||||||
n++;
|
|
||||||
i += 1 + 2 * k;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wire payload accepted by the signature endpoint and
|
|
||||||
/// persisted by <see cref="EstimateSignatureFileHelper"/>.
|
|
||||||
/// Mirrors <c>PostIt.Models.SignaturePadData</c>'s JSON shape
|
|
||||||
/// (without the disk-only envelope fields) so the two sides
|
|
||||||
/// stay trivially compatible.
|
|
||||||
/// </summary>
|
|
||||||
public class SignaturePadPayload
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Normalised coordinate upper bound. Must be
|
|
||||||
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
|
|
||||||
/// (10_000) today; declared as a property so a future
|
|
||||||
/// resolution change can be replayed against the same
|
|
||||||
/// wire format.
|
|
||||||
/// </summary>
|
|
||||||
public int CoordinateMax { get; set; } = 10_000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Client-reported capture time. The server may ignore
|
|
||||||
/// this for ordering (UTC now is the truth) but keeps it
|
|
||||||
/// for round-trip display.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CapturedAtUtc { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wire strokes. See
|
|
||||||
/// <c>PostIt.Models.SignaturePadData</c> for the format.
|
|
||||||
/// </summary>
|
|
||||||
public int[] Strokes { get; set; } = Array.Empty<int>();
|
|
||||||
}
|
|
||||||
111
src/Yavsc.Server/Helpers/EstimateSignaturePersister.cs
Normal file
111
src/Yavsc.Server/Helpers/EstimateSignaturePersister.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#nullable enable annotations
|
||||||
|
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
|
||||||
|
namespace Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared signature-persistence logic for estimates, extracted from
|
||||||
|
/// <c>BillingController.Sign</c>. Stages the <see cref="Signature"/>
|
||||||
|
/// row (find-or-add by <c>(EstimateId, Type)</c>) for the PostIt
|
||||||
|
/// wire-format payload — but does <b>not</b> call
|
||||||
|
/// <c>SaveChanges</c>. The caller owns the transaction so it can
|
||||||
|
/// combine the signature write with its own changes (e.g. setting a
|
||||||
|
/// validation date or a query status) in a single
|
||||||
|
/// <c>SaveChanges</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The <see cref="Signature.Strokes"/> column is the source of
|
||||||
|
/// truth; there is no on-disk copy, so no disk-quota accounting is
|
||||||
|
/// involved.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class EstimateSignaturePersister
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stage a signature for <paramref name="estimateId"/> signed by
|
||||||
|
/// <paramref name="signerId"/> as <paramref name="type"/>. Upserts
|
||||||
|
/// the <see cref="Signature"/> row for the
|
||||||
|
/// <c>(EstimateId, Type)</c> pair, writing the wire payload
|
||||||
|
/// (<c>Strokes</c>, <c>CoordinateMax</c>, <c>CapturedAtUtc</c>)
|
||||||
|
/// and <paramref name="signerId"/>. Returns the staged
|
||||||
|
/// <see cref="Signature"/>; the caller persists with
|
||||||
|
/// <c>SaveChanges</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<Signature> StageAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
long estimateId,
|
||||||
|
SignatureType type,
|
||||||
|
string signerId,
|
||||||
|
SignaturePadPayload payload,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (db is null) throw new ArgumentNullException(nameof(db));
|
||||||
|
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
||||||
|
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
|
||||||
|
|
||||||
|
// Find-or-add: the (EstimateId, Type) pair is unique, so a
|
||||||
|
// second submission for the same side of the estimate
|
||||||
|
// replaces the previous signature. EF translates this into a
|
||||||
|
// single UPDATE when the row exists and an INSERT otherwise;
|
||||||
|
// the unique index in ApplicationDbContext is the
|
||||||
|
// database-level guarantee that the contract holds if two
|
||||||
|
// requests race.
|
||||||
|
var signature = await db.Signatures
|
||||||
|
.FirstOrDefaultAsync(s => s.EstimateId == estimateId && s.Type == type, token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (signature is null)
|
||||||
|
{
|
||||||
|
signature = new Signature
|
||||||
|
{
|
||||||
|
EstimateId = estimateId,
|
||||||
|
SignerId = signerId,
|
||||||
|
Type = type,
|
||||||
|
};
|
||||||
|
db.Signatures.Add(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
signature.SignerId = signerId;
|
||||||
|
signature.CoordinateMax = payload.CoordinateMax;
|
||||||
|
signature.Strokes = payload.Strokes;
|
||||||
|
signature.CapturedAtUtc = payload.CapturedAtUtc;
|
||||||
|
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire payload accepted by the signature path and staged by
|
||||||
|
/// <see cref="EstimateSignaturePersister"/>. Mirrors
|
||||||
|
/// <c>PostIt.Models.SignaturePadData</c>'s JSON shape (without the
|
||||||
|
/// disk-only envelope fields) so the two sides stay trivially
|
||||||
|
/// compatible.
|
||||||
|
/// </summary>
|
||||||
|
public class SignaturePadPayload
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Normalised coordinate upper bound. Must be
|
||||||
|
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
|
||||||
|
/// (10_000) today; declared as a property so a future
|
||||||
|
/// resolution change can be replayed against the same
|
||||||
|
/// wire format.
|
||||||
|
/// </summary>
|
||||||
|
public int CoordinateMax { get; set; } = 10_000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-reported capture time. The server may ignore
|
||||||
|
/// this for ordering (UTC now is the truth) but keeps it
|
||||||
|
/// for round-trip display.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CapturedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire strokes. See
|
||||||
|
/// <c>PostIt.Models.SignaturePadData</c> for the format.
|
||||||
|
/// </summary>
|
||||||
|
public int[] Strokes { get; set; } = Array.Empty<int>();
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,8 @@ namespace Yavsc.Models.Billing;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One captured signature, attached to a single
|
/// One captured signature, attached to a single
|
||||||
/// <see cref="Estimate"/>. Multiple versions are allowed per
|
/// <see cref="Estimate"/>. Multiple versions are allowed per
|
||||||
/// (EstimateId, Type, SignerId) tuple — the controller reads
|
/// (EstimateId, Type, SignerId) tuple — the controller reads the
|
||||||
/// the most recent when asked. The wire-format payload is the
|
/// most recent when asked. The wire-format payload is the
|
||||||
/// same <c>int[]</c> shape PostIt produces (see
|
/// same <c>int[]</c> shape PostIt produces (see
|
||||||
/// <c>PostIt.Models.SignaturePadData</c>): a length-prefixed
|
/// <c>PostIt.Models.SignaturePadData</c>): a length-prefixed
|
||||||
/// sequence of strokes, each stroke being
|
/// sequence of strokes, each stroke being
|
||||||
|
|
@ -16,13 +16,12 @@ namespace Yavsc.Models.Billing;
|
||||||
/// CoordinateMax]</c>.
|
/// CoordinateMax]</c>.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Why a separate table (instead of a JSON column on
|
/// The <see cref="Strokes"/> column is the source of truth: the
|
||||||
/// <see cref="Estimate"/>): the jalon 1 spec calls for at least
|
/// signature is captured and stored entirely in-app (PostIt) and
|
||||||
/// two distinct signatures per estimate cycle (provider
|
/// round-tripped as JSON, with no on-disk copy. A dedicated table
|
||||||
/// validation + client agreement) and the audit value of
|
/// (instead of a JSON column on <see cref="Estimate"/>) keeps the
|
||||||
/// preserving superseded versions. A dedicated table also keeps
|
/// <see cref="Estimate"/> row narrow for list views and preserves
|
||||||
/// the <see cref="Estimate"/> row narrow, which matters for
|
/// superseded versions for audit.
|
||||||
/// list views.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Signature
|
public class Signature
|
||||||
|
|
@ -75,16 +74,4 @@ public class Signature
|
||||||
public int[] Strokes { get; set; } = Array.Empty<int>();
|
public int[] Strokes { get; set; } = Array.Empty<int>();
|
||||||
|
|
||||||
public DateTime CapturedAtUtc { get; set; }
|
public DateTime CapturedAtUtc { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the JSON-serialised wire payload on disk,
|
|
||||||
/// relative to <c>UserFilesDirName</c>. The disk copy is the
|
|
||||||
/// source of truth for the wire bytes; the <see cref="Strokes"/>
|
|
||||||
/// column is a denormalised index for queries. They are
|
|
||||||
/// written together in the same transaction by the
|
|
||||||
/// controller; the migration should keep them in sync
|
|
||||||
/// through <c>ApplicationDbContext.SaveChanges</c>.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string FilePath { get; set; }
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue