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:
Paul Schneider 2026-09-21 16:15:18 +01:00
commit 709846c58d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
10 changed files with 5249 additions and 430 deletions

View file

@ -401,79 +401,12 @@ namespace Yavsc.ApiControllers
Strokes = body.Strokes,
};
// Disk write first: a disk failure shouldn't leave
// a Signature row pointing at a file that doesn't
// exist. The file helper throws on filesystem
// problems and propagates here.
FileReceivedInfo fi;
try
{
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;
}
// The Signature row is staged by the shared persister; we
// SaveChanges here so the signature write is one
// transaction. The Strokes column is the source of truth —
// there is no on-disk copy to roll back on failure.
var signature = await EstimateSignaturePersister.StageAsync(
dbContext, id, type, userId, payload, token);
try
{
@ -482,10 +415,6 @@ namespace Yavsc.ApiControllers
catch (Exception ex)
{
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 });
}