2026-08-30 23:55:09 +01:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
2023-03-19 17:57:55 +00:00
|
|
|
using Microsoft.AspNetCore.Mvc;
|
2026-09-22 03:22:01 +01:00
|
|
|
using Microsoft.AspNetCore.Mvc.Razor;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
using Microsoft.EntityFrameworkCore;
|
2026-09-22 03:22:01 +01:00
|
|
|
using Microsoft.Extensions.Options;
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
using Yavsc.Abstract.Workflow;
|
2017-02-03 15:25:47 +01:00
|
|
|
using Yavsc.Helpers;
|
|
|
|
|
using Yavsc.Models;
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
using Yavsc.Models.Billing;
|
2018-03-26 19:27:29 +02:00
|
|
|
using Yavsc.Services;
|
2026-09-06 19:16:41 +01:00
|
|
|
using Yavsc.Server.Helpers;
|
2018-06-15 15:44:21 +02:00
|
|
|
using Yavsc.ViewModels.FrontOffice;
|
2026-09-22 03:22:01 +01:00
|
|
|
using Yavsc.ViewModels.Gen;
|
2017-02-03 15:25:47 +01:00
|
|
|
|
|
|
|
|
namespace Yavsc.ApiControllers
|
|
|
|
|
{
|
2026-08-30 23:55:09 +01:00
|
|
|
[Authorize]
|
2026-08-20 20:50:52 +01:00
|
|
|
[Route(Constants.APIPrefix + "/front")]
|
2020-10-09 19:35:39 +01:00
|
|
|
public class FrontOfficeApiController : Controller
|
2017-02-03 15:25:47 +01:00
|
|
|
{
|
|
|
|
|
ApplicationDbContext dbContext;
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
private readonly IBillingService billing;
|
|
|
|
|
private readonly ILogger logger;
|
2026-09-22 03:22:01 +01:00
|
|
|
private readonly SiteSettings siteSettings;
|
|
|
|
|
private readonly IViewEngine viewEngine;
|
2020-10-09 19:35:39 +01:00
|
|
|
|
2026-09-22 03:22:01 +01:00
|
|
|
// The view-rendering dependencies (siteSettings, viewEngine) are
|
|
|
|
|
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
|
|
|
|
// the Api host where Program.cs wires AddControllersWithViews +
|
|
|
|
|
// SiteSettings. The accept/reject endpoints (and the Api integration
|
|
|
|
|
// test host, which composes with AddControllers alone) never touch
|
|
|
|
|
// them, so they must not be required for controller activation.
|
|
|
|
|
//
|
|
|
|
|
// .NET 10 does not register a single IViewEngine service (the view
|
|
|
|
|
// engine is registered as IRazorViewEngine and surfaced through
|
|
|
|
|
// MvcViewOptions.ViewEngines). IRazorViewEngine (RazorViewEngine)
|
|
|
|
|
// implements IViewEngine, so we inject that and hand it to
|
|
|
|
|
// TeXHelpers.RenderViewToString as an IViewEngine. RenderViewToString
|
|
|
|
|
// builds its ActionContext from the controller's own ControllerContext,
|
|
|
|
|
// so no IActionContextAccessor is needed (and that type is deprecated
|
|
|
|
|
// in .NET 10).
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
public FrontOfficeApiController(
|
|
|
|
|
ApplicationDbContext context,
|
|
|
|
|
ILoggerFactory loggerFactory,
|
2026-09-22 03:22:01 +01:00
|
|
|
IOptions<SiteSettings> siteSettings = null,
|
|
|
|
|
IRazorViewEngine viewEngine = null,
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
IBillingService billing = null)
|
2017-02-03 15:25:47 +01:00
|
|
|
{
|
|
|
|
|
dbContext = context;
|
2026-09-06 19:16:41 +01:00
|
|
|
this.billing = billing ?? new BillingService(context);
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
2026-09-22 03:22:01 +01:00
|
|
|
this.siteSettings = siteSettings?.Value;
|
|
|
|
|
this.viewEngine = viewEngine as IViewEngine;
|
2017-02-03 15:25:47 +01:00
|
|
|
}
|
|
|
|
|
|
2020-10-09 19:35:39 +01:00
|
|
|
[HttpGet("profiles/{actCode}")]
|
2025-02-24 23:29:48 +00:00
|
|
|
async Task <IEnumerable<PerformerProfileViewModel>> Profiles(string actCode)
|
2017-02-03 15:25:47 +01:00
|
|
|
{
|
2025-02-24 23:29:48 +00:00
|
|
|
return await dbContext.ListPerformersAsync(billing, actCode);
|
2017-02-03 15:25:47 +01:00
|
|
|
}
|
2018-02-25 03:33:54 +01:00
|
|
|
|
2026-09-22 03:22:01 +01:00
|
|
|
// GET: api/front/query/{queryId}/estimate.tex
|
|
|
|
|
//
|
|
|
|
|
// Renders the estimate linked to the query (Estimate.CommandId ==
|
|
|
|
|
// queryId) as a LaTeX source document. The view is a generation
|
|
|
|
|
// template (Layout = "null"), not a web page: it is returned with
|
|
|
|
|
// a text/x-tex content type so the caller can save it as a .tex
|
|
|
|
|
// file. Either party (client or provider) or an admin may read it.
|
|
|
|
|
[HttpGet("query/{queryId}/estimate.tex")]
|
|
|
|
|
public async Task<IActionResult> EstimateTex(long queryId, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
if (viewEngine is null || siteSettings is null)
|
|
|
|
|
return Problem("TeX rendering is not configured on this host.",
|
|
|
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
|
|
|
|
|
|
|
|
var estimate = await LoadEstimateForRenderAsync(queryId, token);
|
|
|
|
|
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
|
|
|
|
|
|
|
|
|
var uid = User.GetUserId();
|
|
|
|
|
if (!User.IsInRole(Constants.AdminGroupName)
|
|
|
|
|
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
|
|
|
|
return Forbid();
|
|
|
|
|
|
|
|
|
|
PrepareEstimateViewBag(estimate);
|
|
|
|
|
Response.ContentType = "text/x-tex";
|
|
|
|
|
return View("Estimate_tex", estimate);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET: api/front/query/{queryId}/estimate.pdf
|
|
|
|
|
//
|
|
|
|
|
// Renders the same LaTeX template to a string, compiles it to PDF
|
|
|
|
|
// via texi2pdf (see SiteSettings.GenerateEstimatePdf), and streams
|
|
|
|
|
// the generated PDF bytes back. Requires /usr/bin/texi2pdf on the
|
|
|
|
|
// server host; on hosts without it the endpoint returns a problem.
|
|
|
|
|
[HttpGet("query/{queryId}/estimate.pdf")]
|
|
|
|
|
public async Task<IActionResult> EstimatePdf(long queryId, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
if (viewEngine is null || siteSettings is null)
|
|
|
|
|
return Problem("PDF rendering is not configured on this host.",
|
|
|
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
|
|
|
|
|
|
|
|
var estimate = await LoadEstimateForRenderAsync(queryId, token);
|
|
|
|
|
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
|
|
|
|
|
|
|
|
|
var uid = User.GetUserId();
|
|
|
|
|
if (!User.IsInRole(Constants.AdminGroupName)
|
|
|
|
|
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
|
|
|
|
return Forbid();
|
|
|
|
|
|
|
|
|
|
PrepareEstimateViewBag(estimate);
|
|
|
|
|
|
|
|
|
|
var baseFileName = $"estimate-{estimate.Id}";
|
|
|
|
|
// Resolve the bills dir to an absolute path once: appsettings
|
|
|
|
|
// configures it as a relative path ("bills"), and both the
|
|
|
|
|
// generation (GenerateEstimatePdf) and the read-back below must
|
|
|
|
|
// agree on the same location regardless of the host's cwd.
|
|
|
|
|
var billsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
|
|
|
|
|
var model = new PdfGenerationViewModel
|
|
|
|
|
{
|
|
|
|
|
BaseFileName = baseFileName,
|
|
|
|
|
DestDir = billsDir,
|
|
|
|
|
Temp = siteSettings.TempDir,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// 1. Render the LaTeX template (Estimate_tex) to a TeX source
|
|
|
|
|
// string, using the estimate as the view model.
|
|
|
|
|
model.TeXSource = this.RenderViewToString(viewEngine, "Estimate_tex", estimate);
|
|
|
|
|
// 2. Render the generation view (Estimate_pdf). Its @{} block
|
|
|
|
|
// calls SiteSettings.GenerateEstimatePdf(Model), which
|
|
|
|
|
// compiles the TeX to PDF on disk as a side effect of the
|
|
|
|
|
// render. The HTML the view emits is not what we return —
|
|
|
|
|
// we stream the generated PDF bytes — but driving the
|
|
|
|
|
// generation through the view keeps the template as the
|
|
|
|
|
// output producer, per the original design.
|
|
|
|
|
this.RenderViewToString(viewEngine, "Estimate_pdf", model);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
|
|
|
|
|
return Problem("TeX render failed: " + ex.Message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!model.Generated)
|
|
|
|
|
return Problem("PDF generation failed: "
|
|
|
|
|
+ (model.GenerationErrorMessage?.Value ?? "unknown error"));
|
|
|
|
|
|
|
|
|
|
var pdfPath = System.IO.Path.Combine(billsDir, baseFileName + ".pdf");
|
|
|
|
|
var bytes = await System.IO.File.ReadAllBytesAsync(pdfPath, token);
|
|
|
|
|
return File(bytes, "application/pdf", baseFileName + ".pdf");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Loads the estimate linked to a query with the navigation the
|
|
|
|
|
// Estimate_tex template reads (client + performer profile + bill).
|
|
|
|
|
private Task<Estimate?> LoadEstimateForRenderAsync(long queryId, CancellationToken token)
|
|
|
|
|
=> dbContext.Estimates
|
|
|
|
|
.Where(e => e.CommandId == queryId)
|
|
|
|
|
.OrderByDescending(e => e.Id)
|
|
|
|
|
.Include(e => e.Bill)
|
|
|
|
|
.Include(e => e.Query).ThenInclude(q => q!.Client).ThenInclude(c => c!.PostalAddress)
|
|
|
|
|
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.OrganizationAddress)
|
|
|
|
|
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer)
|
|
|
|
|
.FirstOrDefaultAsync(token);
|
|
|
|
|
|
|
|
|
|
// Sets the ViewBag keys the Estimate_tex template reads. A devis
|
|
|
|
|
// is never a bill (AsBill=false) and is never acquitted.
|
|
|
|
|
private void PrepareEstimateViewBag(Estimate estimate)
|
|
|
|
|
{
|
|
|
|
|
ViewBag.AsBill = false;
|
|
|
|
|
ViewBag.Acquitted = false;
|
|
|
|
|
ViewBag.BillsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
|
|
|
|
|
ViewBag.AvatarsDir = new System.IO.DirectoryInfo(siteSettings.Avatars).FullName;
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
// 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 async Task<IActionResult> AcceptQuery(
|
|
|
|
|
string billingCode,
|
|
|
|
|
long queryId,
|
|
|
|
|
[FromBody] QueryAcceptanceRequest? body,
|
|
|
|
|
CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
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;
|
2026-09-22 03:22:01 +01:00
|
|
|
// actingRole is the role the caller is signing / accepting
|
|
|
|
|
// as. It defaults to the role inferred from the caller's
|
|
|
|
|
// identity, but when a signature is submitted the caller
|
|
|
|
|
// declares the side (Pro/Client) and the server honors it
|
|
|
|
|
// only if the caller is that party. This disambiguates a
|
|
|
|
|
// user who is both parties (where inference would always
|
|
|
|
|
// pick the provider) and makes "write only for its author"
|
|
|
|
|
// explicit.
|
|
|
|
|
ActorRole actingRole = role;
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
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" });
|
|
|
|
|
|
2026-09-22 03:22:01 +01:00
|
|
|
var uid = User.GetUserId();
|
|
|
|
|
bool isProvider = query.PerformerId == uid;
|
|
|
|
|
bool isClient = query.ClientId == uid;
|
|
|
|
|
|
|
|
|
|
// The caller declares which side they sign as. Null
|
|
|
|
|
// falls back to the role inferred from identity.
|
|
|
|
|
var declared = body!.SignatureType;
|
|
|
|
|
if (declared.HasValue)
|
|
|
|
|
{
|
|
|
|
|
if (declared.Value == SignatureType.Pro && !isProvider)
|
|
|
|
|
return Forbid();
|
|
|
|
|
if (declared.Value == SignatureType.Client && !isClient)
|
|
|
|
|
return Forbid();
|
|
|
|
|
actingRole = declared.Value == SignatureType.Pro
|
|
|
|
|
? ActorRole.Provider
|
|
|
|
|
: ActorRole.Client;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var type = actingRole == ActorRole.Provider
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
? SignatureType.Pro
|
|
|
|
|
: SignatureType.Client;
|
|
|
|
|
|
|
|
|
|
var payload = new SignaturePadPayload
|
|
|
|
|
{
|
2026-09-22 03:22:01 +01:00
|
|
|
CoordinateMax = body.CoordinateMax,
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
|
|
|
|
|
Strokes = body.Strokes!,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 03:22:01 +01:00
|
|
|
if (actingRole == ActorRole.Provider)
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
estimate.ProviderValidationDate = DateTime.UtcNow;
|
|
|
|
|
else
|
|
|
|
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 17:33:32 +01:00
|
|
|
// The party-specific status records who accepted: the
|
|
|
|
|
// provider (ProAccepted) or the client (ClientAccepted).
|
|
|
|
|
// An admin accepting (neither party) falls back to the
|
|
|
|
|
// legacy generic Accepted.
|
2026-09-22 03:22:01 +01:00
|
|
|
query.Status = actingRole switch
|
2026-09-21 17:33:32 +01:00
|
|
|
{
|
|
|
|
|
ActorRole.Provider => QueryStatus.ProAccepted,
|
|
|
|
|
ActorRole.Client => QueryStatus.ClientAccepted,
|
|
|
|
|
_ => QueryStatus.Accepted,
|
|
|
|
|
};
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
|
|
|
|
|
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.
|
2018-06-15 15:44:21 +02:00
|
|
|
[HttpPost("query/reject")]
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
public async Task<IActionResult> RejectQuery(
|
|
|
|
|
string billingCode,
|
|
|
|
|
long queryId,
|
|
|
|
|
[FromBody] QueryAcceptanceRequest? body,
|
|
|
|
|
CancellationToken token)
|
2018-02-25 03:33:54 +01:00
|
|
|
{
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
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(),
|
|
|
|
|
});
|
2018-02-25 03:33:54 +01:00
|
|
|
}
|
2025-02-24 23:29:48 +00:00
|
|
|
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
// 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)
|
2024-02-25 18:05:10 +00:00
|
|
|
{
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
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());
|
2024-02-25 18:05:10 +00:00
|
|
|
}
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
|
|
|
|
|
private static bool HasSignature(QueryAcceptanceRequest? body)
|
|
|
|
|
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
|
2017-02-03 15:25:47 +01:00
|
|
|
}
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
|
|
|
|
|
internal enum ActorRole { None, Provider, Client, Admin }
|
2017-02-17 18:18:42 +01:00
|
|
|
}
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
|
|
|
|
|
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; }
|
2026-09-22 03:22:01 +01:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Which signature is being submitted:
|
|
|
|
|
/// <see cref="SignatureType.Pro"/> (the provider) or
|
|
|
|
|
/// <see cref="SignatureType.Client"/>. When <see cref="Strokes"/>
|
|
|
|
|
/// is present this declares the side the caller is signing as;
|
|
|
|
|
/// the server honors it only if the caller is that party
|
|
|
|
|
/// (<c>query.PerformerId</c> for Pro, <c>query.ClientId</c>
|
|
|
|
|
/// for Client). This disambiguates the case where one user is
|
|
|
|
|
/// both parties, and makes "write only for its author"
|
|
|
|
|
/// explicit. When null, the server falls back to the role
|
|
|
|
|
/// inferred from the caller's identity.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public SignatureType? SignatureType { get; set; }
|
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>
2026-09-21 16:15:18 +01:00
|
|
|
}
|
|
|
|
|
}
|