yavsc/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs

506 lines
23 KiB
C#
Raw Normal View History

#nullable enable annotations
using System.Diagnostics;
2026-08-30 23:55:09 +01:00
using Microsoft.AspNetCore.Authorization;
2023-03-19 17:57:55 +00:00
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Workflow;
2017-02-03 15:25:47 +01:00
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
Gestion des fichiers utilisateur et pièces jointes (demande/devis) Espace de stockage personnel géré depuis PostIt, avec attachement par référence (sans duplication) des fichiers à la demande du client (NominativeServiceCommand) et au devis du fournisseur (Estimate). Backend: - UploadedFile.OwnerId (FK ApplicationUser, nullable) ; les téléversements vers api/v1/fs créent/mettent à jour une ligne propriétaire. - Tables de jointure EstimateAttachedFile et QueryAttachedFile (PK composite), migration UserFileAttachments. - EstimateApiController: GET/POST/DELETE estimate/{id}/attachments (fournisseur uniquement, fichier devant appartenir au caller). - FrontOfficeApiController: GET/POST/DELETE front/query/{id}/attachments (client uniquement). - FileSystemApiController: GET fs/file/{fileId} sert les octets depuis le host Blogs (autorisation propriétaire ou partie au devis/demande via DB partagée) — le host API ne gère que les métadonnées, contrainte des conteneurs docker à disques distincts. Client .NET: - UserFilesApiClient (list/upload/delete/download) pointant vers BlogsApiUrl. - DTOs UserDirectoryDto/FileEntryDto/AttachmentDto/FileReceivedDto/UploadFile. - Méthodes d'attachement sur EstimateApiClient et FrontOfficeApiClient. PostIt (Avalonia): - Page « Mes fichiers » (naviguer/téléverser/télécharger/supprimer), servant aussi de sélecteur pour l'attachement. - Section pièces jointes dans l'édition du devis (fournisseur) et le détail de la demande (client), avec ajout depuis My Files, détachement, téléchargement. - Enregistrement DI + mapping ViewLocator + bouton d'accès. Tests: - EstimateEditionPageViewModelTests mis à jour (nouveau ctor fsClient, historique d'appels du stub pour les assertions post-envoi). Nommage HTTP/méthodes en anglais, documentation en français. Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:35:36 +01:00
using Yavsc.Models.Blog;
using Yavsc.Models.Messaging;
2018-03-26 19:27:29 +02:00
using Yavsc.Services;
using Yavsc.Server.Helpers;
2018-06-15 15:44:21 +02:00
using Yavsc.ViewModels.FrontOffice;
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;
private readonly IBillingService billing;
private readonly ILogger logger;
private readonly SiteSettings siteSettings;
2026-09-22 16:34:51 +01:00
private readonly IRazorViewEngine viewEngine;
private readonly IYavscMessageSender messageSender;
2020-10-09 19:35:39 +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).
public FrontOfficeApiController(
ApplicationDbContext context,
ILoggerFactory loggerFactory,
IYavscMessageSender messageSender,
IOptions<SiteSettings> siteSettings = null,
IRazorViewEngine viewEngine = null,
IBillingService billing = null)
2017-02-03 15:25:47 +01:00
{
dbContext = context;
this.messageSender = messageSender;
this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
this.siteSettings = siteSettings?.Value;
2026-09-22 16:34:51 +01:00
this.viewEngine = viewEngine;
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
// 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)
2026-09-22 16:34:51 +01:00
{
string msg = "PDF rendering is not configured on this host.";
logger.LogError(msg);
return Problem(msg,
statusCode: StatusCodes.Status503ServiceUnavailable);
2026-09-22 16:34:51 +01:00
}
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,
};
var stopwatch = Stopwatch.StartNew();
FileInfo? renderedPdf = null;
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.
renderedPdf = model.GenerateEstimatePdf();
if (renderedPdf is FileInfo fo && fo.Exists)
2026-09-22 16:34:51 +01:00
{
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: fo.Length,
actorUserId: uid);
return File(fo.OpenRead(), "application/pdf");
2026-09-22 16:34:51 +01:00
}
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: 0,
actorUserId: uid);
2026-09-22 16:34:51 +01:00
string msg = "TeX render failed: " + model.GenerationErrorMessage;
logger.LogError(msg);
return Problem(msg);
}
catch (Exception ex)
{
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: renderedPdf?.Length ?? 0,
actorUserId: uid);
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
return Problem("TeX render failed: " + ex.Message);
}
2026-09-22 16:34:51 +01:00
}
// Loads the estimate linked to a query with the navigation the
// Estimate_tex template reads (client + performer profile + bill
// + the captured signatures, drawn as tikz at the foot of the devis).
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.Signatures)
.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;
}
// 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;
ClientEstimateSignedEvent? signedEvent = null;
// 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;
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 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
? SignatureType.Pro
: SignatureType.Client;
var payload = new SignaturePadPayload
{
CoordinateMax = body.CoordinateMax,
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 });
}
if (actingRole == ActorRole.Provider)
estimate.ProviderValidationDate = DateTime.UtcNow;
else
{
estimate.ClientValidationDate = DateTime.UtcNow;
var signer = await dbContext.Users.SingleAsync(u => u.Id == uid, token);
signedEvent = new ClientEstimateSignedEvent(estimate, signer.UserName);
}
}
// 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.
query.Status = actingRole switch
{
ActorRole.Provider => QueryStatus.ProAccepted,
ActorRole.Client => QueryStatus.ClientAccepted,
_ => 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 });
}
if (signedEvent is not null)
await messageSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
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")]
public async Task<IActionResult> RejectQuery(
string billingCode,
long queryId,
[FromBody] QueryAcceptanceRequest? body,
CancellationToken token)
2018-02-25 03:33:54 +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
// 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
{
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
}
private static bool HasSignature(QueryAcceptanceRequest? body)
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
Gestion des fichiers utilisateur et pièces jointes (demande/devis) Espace de stockage personnel géré depuis PostIt, avec attachement par référence (sans duplication) des fichiers à la demande du client (NominativeServiceCommand) et au devis du fournisseur (Estimate). Backend: - UploadedFile.OwnerId (FK ApplicationUser, nullable) ; les téléversements vers api/v1/fs créent/mettent à jour une ligne propriétaire. - Tables de jointure EstimateAttachedFile et QueryAttachedFile (PK composite), migration UserFileAttachments. - EstimateApiController: GET/POST/DELETE estimate/{id}/attachments (fournisseur uniquement, fichier devant appartenir au caller). - FrontOfficeApiController: GET/POST/DELETE front/query/{id}/attachments (client uniquement). - FileSystemApiController: GET fs/file/{fileId} sert les octets depuis le host Blogs (autorisation propriétaire ou partie au devis/demande via DB partagée) — le host API ne gère que les métadonnées, contrainte des conteneurs docker à disques distincts. Client .NET: - UserFilesApiClient (list/upload/delete/download) pointant vers BlogsApiUrl. - DTOs UserDirectoryDto/FileEntryDto/AttachmentDto/FileReceivedDto/UploadFile. - Méthodes d'attachement sur EstimateApiClient et FrontOfficeApiClient. PostIt (Avalonia): - Page « Mes fichiers » (naviguer/téléverser/télécharger/supprimer), servant aussi de sélecteur pour l'attachement. - Section pièces jointes dans l'édition du devis (fournisseur) et le détail de la demande (client), avec ajout depuis My Files, détachement, téléchargement. - Enregistrement DI + mapping ViewLocator + bouton d'accès. Tests: - EstimateEditionPageViewModelTests mis à jour (nouveau ctor fsClient, historique d'appels du stub pour les assertions post-envoi). Nommage HTTP/méthodes en anglais, documentation en français. Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:35:36 +01:00
// GET: api/v1/front/query/{queryId}/attachments?billingCode=…
//
// Liste les fichiers de l'espace perso du client rattachés à la
// demande, par référence (UploadedFile). Lecture ouverte aux deux
// parties (client/fournisseur) de la demande et aux admin ; le
// téléchargement des octets est servi par le host Blogs.
[HttpGet("query/{queryId}/attachments")]
public async Task<IActionResult> GetQueryAttachments(
string billingCode, long queryId, 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" });
var attached = await dbContext.QueryAttachedFiles
.Where(a => a.CommandId == queryId)
.Select(a => new
{
fileId = a.FileId,
path = a.File.Path,
size = a.File.Length,
contentType = a.File.ContentType,
ownerId = a.File.OwnerId
})
.ToListAsync(token);
return Ok(attached);
}
// POST: api/v1/front/query/{queryId}/attachments?billingCode=… { fileId }
//
// Attache un fichier de l'espace perso du client à sa demande, par
// référence. Réservé au client (rôle Client) : c'est lui qui
// documente sa demande. Le fichier doit lui appartenir.
[HttpPost("query/{queryId}/attachments")]
public async Task<IActionResult> AttachQueryFile(
string billingCode, long queryId, [FromBody] AttachFileRequest? 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" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
return BadRequest(new { error = "fileId required" });
var uid = User.GetUserId();
var file = await dbContext.UploadedFiles
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
if (file == null) return BadRequest(new { error = "file not found" });
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var existing = await dbContext.QueryAttachedFiles
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == body.FileId, token);
if (existing != null) return Ok(new { queryId, fileId = body.FileId });
dbContext.QueryAttachedFiles.Add(new QueryAttachedFile
{
CommandId = queryId,
FileId = body.FileId
});
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new { queryId, fileId = body.FileId });
}
// DELETE: api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=…
//
// Détache un fichier de la demande. Réservé au client.
[HttpDelete("query/{queryId}/attachments/{fileId}")]
public async Task<IActionResult> DetachQueryFile(
string billingCode, long queryId, long fileId, 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" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = await dbContext.QueryAttachedFiles
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == fileId, token);
if (link == null) return NotFound();
dbContext.QueryAttachedFiles.Remove(link);
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new { queryId, fileId });
}
2017-02-03 15:25:47 +01:00
}
internal enum ActorRole { None, Provider, Client, Admin }
}