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>
177 lines
No EOL
7.8 KiB
C#
177 lines
No EOL
7.8 KiB
C#
using System;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Yavsc.Api.Client;
|
|
|
|
/// <summary>
|
|
/// HTTP client for the front-office query routes
|
|
/// (<c>api/v1/front/query/accept</c> / <c>.../reject</c>, served by
|
|
/// <see cref="Yavsc.ApiControllers.FrontOfficeApiController"/>). Same
|
|
/// DTO↔path mapper shape as <see cref="EstimateApiClient"/>: transport
|
|
/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated
|
|
/// to <see cref="IYavscApiClient"/>.
|
|
///
|
|
/// The server resolves the caller's role (provider vs client vs admin)
|
|
/// from the authenticated user compared to the query's
|
|
/// <c>PerformerId</c>/<c>ClientId</c>; the client does not declare a
|
|
/// role. <paramref name="billingCode"/> is the estimate's
|
|
/// <see cref="EstimateDto.CommandType"/> and <paramref name="queryId"/>
|
|
/// is its <see cref="EstimateDto.CommandId"/>.
|
|
/// </summary>
|
|
public sealed class FrontOfficeApiClient
|
|
{
|
|
private const string PathPrefix = "front";
|
|
|
|
private readonly IYavscApiClient _api;
|
|
private readonly Func<string> _businessBaseAddress;
|
|
|
|
public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress)
|
|
: this(api, () => businessBaseAddress)
|
|
{
|
|
}
|
|
|
|
public FrontOfficeApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
|
|
{
|
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
|
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
|
|
_ = ResolveBusinessBaseAddress();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accept (validate) the query <paramref name="queryId"/> under
|
|
/// billing code <paramref name="billingCode"/>. When
|
|
/// <paramref name="body"/> carries a non-empty signature, the
|
|
/// server stages it on the query's linked estimate and stamps the
|
|
/// matching validation date.
|
|
/// </summary>
|
|
public async Task<QueryAcceptanceResponseDto> AcceptQueryAsync(
|
|
string billingCode,
|
|
long queryId,
|
|
QueryAcceptanceRequestDto body,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(billingCode))
|
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
|
if (queryId <= 0)
|
|
throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
if (body is null)
|
|
throw new ArgumentNullException(nameof(body));
|
|
|
|
var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
|
|
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
|
|
HttpMethod.Post,
|
|
Absolute(path),
|
|
body: body,
|
|
ct: ct).ConfigureAwait(false);
|
|
|
|
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reject the query <paramref name="queryId"/> under billing code
|
|
/// <paramref name="billingCode"/>. A signature is not meaningful on
|
|
/// a rejection; the server refuses one with 400, so this sends no
|
|
/// body.
|
|
/// </summary>
|
|
public async Task<QueryAcceptanceResponseDto> RejectQueryAsync(
|
|
string billingCode,
|
|
long queryId,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(billingCode))
|
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
|
if (queryId <= 0)
|
|
throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
|
|
var path = $"{PathPrefix}/query/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
|
|
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
|
|
HttpMethod.Post,
|
|
Absolute(path),
|
|
body: null,
|
|
ct: ct).ConfigureAwait(false);
|
|
|
|
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Download the estimate linked to query <paramref name="queryId"/>
|
|
/// as a LaTeX (<c>.tex</c>) source document
|
|
/// (<c>GET api/v1/front/query/{queryId}/estimate.tex</c>). Returns
|
|
/// the raw TeX bytes (UTF-8 text).
|
|
/// </summary>
|
|
public Task<byte[]> GetEstimateTexAsync(long queryId, CancellationToken ct = default)
|
|
{
|
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
var path = $"{PathPrefix}/query/{queryId}/estimate.tex";
|
|
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Download the estimate linked to query <paramref name="queryId"/>
|
|
/// as a compiled PDF document
|
|
/// (<c>GET api/v1/front/query/{queryId}/estimate.pdf</c>). Returns
|
|
/// the raw PDF bytes. Requires <c>texi2pdf</c> on the server host;
|
|
/// a non-2xx response throws <see cref="HttpRequestException"/>.
|
|
/// </summary>
|
|
public Task<byte[]> GetEstimatePdfAsync(long queryId, CancellationToken ct = default)
|
|
{
|
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
var path = $"{PathPrefix}/query/{queryId}/estimate.pdf";
|
|
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liste les pièces jointes (fichiers de l'espace perso du client)
|
|
/// rattachées à la demande <paramref name="queryId"/> par référence
|
|
/// (<c>GET front/query/{queryId}/attachments</c>).
|
|
/// </summary>
|
|
public Task<List<AttachmentDto>> GetQueryAttachmentsAsync(string billingCode, long queryId, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(billingCode))
|
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
|
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute(path), ct: ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attache un fichier de l'espace perso du client à sa demande
|
|
/// (<c>POST front/query/{queryId}/attachments</c>). Réservé au client.
|
|
/// </summary>
|
|
public Task AttachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(billingCode))
|
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
|
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
|
return _api.CallAsync(HttpMethod.Post, Absolute(path), body: new { fileId }, ct: ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Détache un fichier de la demande
|
|
/// (<c>DELETE front/query/{queryId}/attachments/{fileId}</c>). Réservé au client.
|
|
/// </summary>
|
|
public Task DetachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(billingCode))
|
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
|
var path = $"{PathPrefix}/query/{queryId}/attachments/{fileId}?billingCode={Uri.EscapeDataString(billingCode)}";
|
|
return _api.CallAsync(HttpMethod.Delete, Absolute(path), ct: ct);
|
|
}
|
|
|
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
|
|
|
private Uri ResolveBusinessBaseAddress()
|
|
{
|
|
var raw = _businessBaseAddress();
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
throw new InvalidOperationException("Business base address is required.");
|
|
|
|
return new Uri(raw, UriKind.Absolute);
|
|
}
|
|
} |