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
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.ViewModels.FrontOffice;
|
||||
|
|
@ -13,13 +16,17 @@ namespace Yavsc.ApiControllers
|
|||
public class FrontOfficeApiController : Controller
|
||||
{
|
||||
ApplicationDbContext dbContext;
|
||||
private readonly IBillingService billing;
|
||||
private readonly ILogger logger;
|
||||
|
||||
private IBillingService billing;
|
||||
|
||||
public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null)
|
||||
public FrontOfficeApiController(
|
||||
ApplicationDbContext context,
|
||||
ILoggerFactory loggerFactory,
|
||||
IBillingService billing = null)
|
||||
{
|
||||
dbContext = context;
|
||||
this.billing = billing ?? new BillingService(context);
|
||||
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
||||
}
|
||||
|
||||
[HttpGet("profiles/{actCode}")]
|
||||
|
|
@ -28,29 +35,183 @@ namespace Yavsc.ApiControllers
|
|||
return await dbContext.ListPerformersAsync(billing, actCode);
|
||||
}
|
||||
|
||||
[HttpPost("query/reject")]
|
||||
public IActionResult RejectQuery(string billingCode, long queryId)
|
||||
{
|
||||
if (billingCode == null) return BadRequest("billingCode");
|
||||
if (queryId == 0) return BadRequest("queryId");
|
||||
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
||||
if (billing == null) return BadRequest();
|
||||
|
||||
billing.Status = QueryStatus.Rejected;
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// POST: api/front/query/accept
|
||||
//
|
||||
// Either party of the query may accept: the provider
|
||||
// (PerformerId) accepts the client's request, or the client
|
||||
// (ClientId) accepts the provider's estimate. An optional
|
||||
// signature (the PostIt wire format) may be sent along; it is
|
||||
// persisted on the estimate linked to the query
|
||||
// (Estimate.CommandId == queryId), and the matching validation
|
||||
// date is stamped — ProviderValidationDate when the provider
|
||||
// accepts, ClientValidationDate when the client accepts.
|
||||
[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");
|
||||
if (queryId == 0) return BadRequest("queryId");
|
||||
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
||||
if (billing == null) return BadRequest();
|
||||
billing.Status = QueryStatus.Accepted;
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
return Ok();
|
||||
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" });
|
||||
|
||||
Signature? signature = null;
|
||||
if (HasSignature(body))
|
||||
{
|
||||
// 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; }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue