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>
This commit is contained in:
parent
eb58fc8862
commit
70ab05522b
31 changed files with 6515 additions and 20 deletions
66
src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs
Normal file
66
src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Yavsc.Api.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Représentation d'un répertoire de l'espace de stockage personnel,
|
||||
/// renvoyée par <c>GET api/v1/fs/{subdir}</c> (host Blogs). Ne dépend pas
|
||||
/// des helpers serveur : c'est un pur DTO de désérialisation.
|
||||
/// </summary>
|
||||
public sealed class UserDirectoryDto
|
||||
{
|
||||
public string? UserName { get; set; }
|
||||
public string? SubPath { get; set; }
|
||||
public FileEntryDto[] Files { get; set; } = Array.Empty<FileEntryDto>();
|
||||
public DirEntryDto[] SubDirectories { get; set; } = Array.Empty<DirEntryDto>();
|
||||
}
|
||||
|
||||
public sealed class FileEntryDto
|
||||
{
|
||||
/// <summary>Identifiant de la ligne UploadedFile, pour l'attachement par référence.</summary>
|
||||
public long? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public long Size { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DirEntryDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Métadonnées d'une pièce jointe (fichier de l'espace perso rattaché à
|
||||
/// un devis ou une demande), renvoyées par les endpoints d'attachement.
|
||||
/// </summary>
|
||||
public sealed class AttachmentDto
|
||||
{
|
||||
public long FileId { get; set; }
|
||||
/// <summary>Chemin relatif dans l'espace perso du propriétaire.</summary>
|
||||
public string? Path { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
public string? OwnerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Métadonnées d'un fichier reçu après un téléversement multipart vers
|
||||
/// <c>POST api/v1/fs/{subdir}</c>. Porte notamment l'identifiant
|
||||
/// <see cref="FileId"/> de la ligne UploadedFile créée côté serveur.
|
||||
/// </summary>
|
||||
public sealed class FileReceivedDto
|
||||
{
|
||||
public long? FileId { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
public long Length { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
public bool QuotaOffense { get; set; }
|
||||
public bool Overridden { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contenu d'un fichier à téléverser (nom, octets, type MIME), à l'image
|
||||
/// de <see cref="BlogUploadFile"/> mais pour l'espace perso générique.
|
||||
/// </summary>
|
||||
public sealed record UploadFile(string FileName, byte[] Content, string? ContentType);
|
||||
|
|
@ -113,6 +113,41 @@ public sealed class EstimateApiClient
|
|||
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liste les pièces jointes (fichiers de l'espace perso du fournisseur)
|
||||
/// rattachées au devis <paramref name="id"/> par référence
|
||||
/// (<c>GET estimate/{id}/attachments</c>).
|
||||
/// </summary>
|
||||
public Task<List<AttachmentDto>> GetAttachmentsAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}/attachments"), ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attache un fichier de l'espace perso du fournisseur au devis
|
||||
/// <paramref name="id"/> par référence (<c>POST estimate/{id}/attachments</c>).
|
||||
/// Réservé au fournisseur côté serveur.
|
||||
/// </summary>
|
||||
public Task AttachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
return _api.CallAsync(HttpMethod.Post, Absolute($"{PathPrefix}/{id}/attachments"),
|
||||
body: new { fileId }, ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détache un fichier du devis <paramref name="id"/>
|
||||
/// (<c>DELETE estimate/{id}/attachments/{fileId}</c>). Réservé au fournisseur.
|
||||
/// </summary>
|
||||
public Task DetachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}/attachments/{fileId}"), ct: ct);
|
||||
}
|
||||
|
||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||
|
||||
private Uri ResolveBusinessBaseAddress()
|
||||
|
|
|
|||
|
|
@ -122,6 +122,48 @@ public sealed class FrontOfficeApiClient
|
|||
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()
|
||||
|
|
|
|||
112
src/Yavsc.Api.Client/UserFilesApiClient.cs
Normal file
112
src/Yavsc.Api.Client/UserFilesApiClient.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Yavsc.Api.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Client HTTP de l'espace de stockage personnel
|
||||
/// (<c>api/v1/fs</c>, servi par le host <c>Yavsc.Blogs</c>). Même forme
|
||||
/// que <see cref="BlogApiClient"/> : le transport (URL de base, JSON,
|
||||
/// Bearer, refresh silencieux sur 401) est délégué à
|
||||
/// <see cref="IYavscApiClient"/> ; cette classe n'est qu'un mappeur
|
||||
/// DTO↔chemin.
|
||||
///
|
||||
/// <para><b>Convention d'URL.</b> Le <c>pathPrefix</c> est relatif au
|
||||
/// segment de version déjà porté par l'URL de base
|
||||
/// (<c>…/api/v1/</c>) : <c>"fs"</c> résout en <c>…/api/v1/fs</c>, qui
|
||||
/// correspond au <c>[Route(APIPrefix + "/fs")]</c> de
|
||||
/// <c>FileSystemApiController</c>. Ne pas ré-inclure <c>api/</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class UserFilesApiClient
|
||||
{
|
||||
private const string PathPrefix = "fs";
|
||||
|
||||
private readonly IYavscApiClient _api;
|
||||
private readonly Uri _baseAddress;
|
||||
|
||||
public UserFilesApiClient(IYavscApiClient api, string blogsBaseAddress)
|
||||
{
|
||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
if (string.IsNullOrEmpty(blogsBaseAddress))
|
||||
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
|
||||
|
||||
_baseAddress = new Uri(blogsBaseAddress);
|
||||
_api.Http.BaseAddress = _baseAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liste le contenu du sous-répertoire <paramref name="subdir"/> de
|
||||
/// l'espace perso de l'utilisateur (<c>GET fs/{subdir}</c>).
|
||||
/// </summary>
|
||||
public Task<UserDirectoryDto> ListAsync(string? subdir = null, CancellationToken ct = default)
|
||||
{
|
||||
var path = string.IsNullOrWhiteSpace(subdir) ? PathPrefix : $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||
return _api.CallAsync<UserDirectoryDto>(HttpMethod.Get, path, ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Téléverse des fichiers dans le sous-répertoire
|
||||
/// <paramref name="subdir"/> (<c>POST fs/{subdir}</c> multipart).
|
||||
/// Renvoie les métadonnées des fichiers reçus, dont l'identifiant
|
||||
/// <see cref="FileReceivedDto.FileId"/> servant à l'attachement.
|
||||
/// </summary>
|
||||
public Task<List<FileReceivedDto>> UploadAsync(string subdir, IReadOnlyCollection<UploadFile> files, CancellationToken ct = default)
|
||||
{
|
||||
if (files is null || files.Count == 0)
|
||||
throw new ArgumentException("At least one file is required.", nameof(files));
|
||||
|
||||
var path = string.IsNullOrWhiteSpace(subdir)
|
||||
? PathPrefix
|
||||
: $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||
|
||||
return _api.CallAsync<List<FileReceivedDto>>(HttpMethod.Post, path, () => CreateMultipartContent(files), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supprime un fichier ou répertoire de l'espace perso
|
||||
/// (<c>DELETE fs/{path}</c>).
|
||||
/// </summary>
|
||||
public Task DeleteAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Path is required.", nameof(path));
|
||||
return _api.CallAsync(HttpMethod.Delete, $"{PathPrefix}/{path.TrimStart('/')}", ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge une pièce jointe référencée par son identifiant
|
||||
/// <see cref="FileReceivedDto.FileId"/> / <see cref="AttachmentDto.FileId"/>
|
||||
/// (<c>GET fs/file/{fileId}</c>). Renvoie les octets bruts ; le serveur
|
||||
/// (host Blogs) autorise l'appelant (propriétaire ou partie au
|
||||
/// devis/demande auquel le fichier est rattaché).
|
||||
/// </summary>
|
||||
public Task<byte[]> DownloadFileAsync(long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
return _api.DownloadAsync(HttpMethod.Get, $"{PathPrefix}/file/{fileId}", ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construit le corps <c>multipart/form-data</c> d'un téléversement :
|
||||
/// une part <c>file</c> par fichier (type MIME retombant sur
|
||||
/// <c>application/octet-stream</c>). Exposé <c>public</c> pour les
|
||||
/// tests backend qui assertent la forme filaire.
|
||||
/// </summary>
|
||||
public static HttpContent CreateMultipartContent(IReadOnlyCollection<UploadFile> files)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileContent = new ByteArrayContent(file.Content);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
|
||||
content.Add(fileContent, "file", file.FileName);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue