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:
Paul Schneider 2026-09-24 16:35:36 +01:00
commit 70ab05522b
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
31 changed files with 6515 additions and 20 deletions

View file

@ -22,7 +22,8 @@ public class EstimateEditionPageViewModelTests
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
}
[Fact]
@ -143,10 +144,8 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType);
@ -216,7 +215,8 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent);
@ -230,11 +230,20 @@ public class EstimateEditionPageViewModelTests
public object? LastBody { get; private set; }
public Exception? Failure { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
/// <summary>Historique complet des appels (le plus récent en dernier).</summary>
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
{
LastMethod = method;
LastPath = path;
LastBody = body;
Calls.Add((method, path, body));
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (Failure is not null)
{
@ -253,9 +262,7 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
Record(method, path, body);
return Task.CompletedTask;
}
@ -265,6 +272,12 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -22,6 +22,7 @@ public static class ServiceCollectionHelpers
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>();
// ViewModels
services.AddSingleton(settings);
@ -77,6 +79,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();

View file

@ -56,6 +56,7 @@ public class ViewLocator : IDataTemplate
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

@ -132,14 +132,18 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
{
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel(
Activity,
Performer,
Form,
_billingClient,
frontClient,
fsClient,
details,
IsReadOnly);
IsReadOnly,
canAttach: !IsReadOnly);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
@ -15,8 +16,17 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
{
private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQueryDetailsDto _details;
/// <summary>
/// Vrai lorsque l'utilisateur courant est le client de la demande
/// et peut y attacher/détacher des fichiers de son espace perso.
/// Le fournisseur consulte et télécharge les pièces mais n'attache
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
/// </summary>
public bool CanAttach { get; }
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; }
@ -52,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public override bool CanNavigateNext
{
get => false;
@ -70,20 +84,27 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
CommandFormSummary form,
BillingApiClient billingClient,
FrontOfficeApiClient frontClient,
UserFilesApiClient fsClient,
BillingQueryDetailsDto details,
bool isReadOnly)
bool isReadOnly,
bool canAttach)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
_details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly;
CanAttach = canAttach;
this.SetInfoStatus("Details de commande charges.");
}
/// <summary>Constructeur pour le designer Avalonia.</summary>
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
[RelayCommand]
private async Task OpenEditorAsync()
{
@ -134,6 +155,110 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true);
}
/// <summary>Charge les pièces jointes de la demande.</summary>
public async Task InitializeAsync()
{
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task LoadQueryAttachmentsAsync()
{
try
{
var list = await _frontClient
.GetQueryAttachmentsAsync(BillingCode, Id)
.ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
// Non bloquant : les pièces jointes sont un complément.
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
}
}
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché à la demande par référence via
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
/// (<see cref="CanAttach"/>).
/// </summary>
[RelayCommand]
private async Task AddQueryAttachmentAsync()
{
if (!CanAttach) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingCode = BillingCode;
var queryId = Id;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
{
if (attachment is null || !CanAttach) return;
IsBusy = true;
try
{
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée de la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is

View file

@ -25,6 +25,7 @@ namespace PostIt.ViewModels;
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id;
@ -68,6 +69,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
public partial long? EstimateId { get; set; }
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
@ -86,10 +95,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
protected set { _ = value; }
}
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
public EstimateEditionPageViewModel(
BillingQuerySummaryDto query,
EstimateApiClient estimateClient,
UserFilesApiClient fsClient)
{
_query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged;
@ -98,6 +111,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
}
/// <summary>Constructeur pour le designer Avalonia.</summary>
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
[RelayCommand]
private void AddLine()
{
@ -141,9 +157,11 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true;
EstimateId = created.Id;
OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
await LoadAttachmentsAsync().ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
@ -171,6 +189,106 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
await app.GoBackAsync().ConfigureAwait(true);
}
/// <summary>
/// Charge les pièces jointes rattachées au devis courant
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
[RelayCommand]
private async Task LoadAttachmentsAsync()
{
if (EstimateId is null || _estimateClient is null) return;
try
{
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
}
}
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché au devis par référence via
/// <c>POST estimate/{id}/attachments</c>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
private async Task AddAttachmentAsync()
{
if (EstimateId is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var estimateId = EstimateId.Value;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
await LoadAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachFileAsync(AttachmentDto? attachment)
{
if (attachment is null || EstimateId is null) return;
IsBusy = true;
try
{
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée du devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
internal EstimateDto BuildPayload()
{
return new EstimateDto

View file

@ -155,6 +155,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
.ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel(
activity,
@ -162,9 +163,12 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
form,
_billingClient,
frontClient,
fsClient,
details,
isReadOnly: false);
isReadOnly: false,
canAttach: false);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (Exception ex)
@ -243,7 +247,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}

View file

@ -35,12 +35,14 @@ public class HomePageViewModel : ViewModelBase
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
}
public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
private async Task OpenActivitiesAsync()
{
@ -101,6 +103,29 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm);
}
/// <summary>
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
/// servie par le host Blogs via <c>api/v1/fs</c>.
/// </summary>
private async Task OpenMyFilesAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new MyFilesViewModel(fsClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can

View file

@ -0,0 +1,229 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Gestion de l'espace de stockage personnel (« My Files ») :
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
/// (fournisseur) ou à une demande (client) par référence : lorsque
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
/// puis revient à la page précédente.
/// </summary>
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly UserFilesApiClient _fs;
private readonly Action<long>? _onFilePicked;
/// <summary>
/// Sous-répertoire courant, relatif à la racine de l'espace perso
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
/// </summary>
[ObservableProperty]
public partial string CurrentPath { get; set; } = string.Empty;
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
? "Mes fichiers"
: $"Mes fichiers / {CurrentPath}";
public bool IsPickerMode => _onFilePicked is not null;
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
[ObservableProperty]
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
{
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
_onFilePicked = onFilePicked;
}
/// <summary>Constructeur pour le designer Avalonia.</summary>
public MyFilesViewModel() : this(null!) { }
public async Task InitializeAsync()
{
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task RefreshAsync()
{
IsBusy = true;
try
{
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
Files.Clear();
SubDirectories.Clear();
if (dir?.Files is not null)
foreach (var f in dir.Files) Files.Add(f);
if (dir?.SubDirectories is not null)
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task OpenDirAsync(DirEntryDto? dir)
{
if (dir is null) return;
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task UpAsync()
{
if (!CanGoUp) return;
CurrentPath = ParentPath(CurrentPath);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
{
if (uploads is null || uploads.Count == 0) return;
IsBusy = true;
try
{
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteFileAsync(FileEntryDto? file)
{
if (file is null) return;
IsBusy = true;
try
{
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
this.SetInfoStatus($"Fichier supprimé.");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null) return;
IsBusy = true;
try
{
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task PickFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null)
{
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
return;
}
var callback = _onFilePicked;
if (callback is null)
{
// Mode gestion : le téléchargement est l'action par défaut.
await DownloadFileAsync(file).ConfigureAwait(true);
return;
}
callback(file.Id.Value);
await GoBackAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task BackAsync()
{
await GoBackAsync().ConfigureAwait(true);
}
private static async Task GoBackAsync()
{
var app = (App?)Application.Current;
if (app is null) throw new InvalidOperationException("Application PostIt indisponible.");
await app.GoBackAsync().ConfigureAwait(true);
}
private static string CombinePath(string current, string name)
{
if (string.IsNullOrWhiteSpace(current)) return name;
return $"{current}/{name}";
}
private static string ParentPath(string current)
{
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
}

View file

@ -1,6 +1,7 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.BillingQueryDetailsPage"
x:DataType="vm:BillingQueryDetailsPageViewModel"
@ -80,6 +81,53 @@
</Grid>
</Border>
<!-- Pièces jointes (par référence à l'espace perso du client) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddQueryAttachmentCommand}"
IsVisible="{Binding CanAttach}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Expander Header="Metadonnees techniques"
IsExpanded="False">
<Border BorderThickness="1"

View file

@ -1,6 +1,7 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel"
@ -133,6 +134,52 @@
</Grid>
</StackPanel>
</Border>
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
IsVisible="{Binding HasSent}">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddAttachmentCommand}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>

View file

@ -34,5 +34,9 @@
Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes fichiers"
Command="{Binding OpenMyFiles}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ContentPage>

View file

@ -0,0 +1,90 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.MyFilesPage"
x:DataType="vm:MyFilesViewModel"
Header="Mes fichiers">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding PathLabel}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="Espace de stockage personnel. Téléversez vos fichiers puis attachez-les à un devis ou une demande."
Opacity="0.7"
TextWrapping="Wrap" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
<Button Grid.Column="2"
Content="Remonter"
Command="{Binding UpCommand}"
IsEnabled="{Binding CanGoUp}" />
<Button Grid.Column="4"
Content="Téléverser…"
Click="Upload_Click"
HorizontalAlignment="Right" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<ItemsControl ItemsSource="{Binding SubDirectories}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:DirEntryDto">
<Button Content="📁 {Binding Name}"
Command="{Binding $parent[vm:MyFilesViewModel].OpenDirCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"
Margin="0,0,0,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:FileEntryDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
VerticalAlignment="Center"
TextWrapping="NoWrap" />
<Button Grid.Column="1"
Content="Joindre"
Command="{Binding $parent[vm:MyFilesViewModel].PickFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:MyFilesViewModel].IsPickerMode}" />
<Button Grid.Column="3"
Content="Télécharger"
Command="{Binding $parent[vm:MyFilesViewModel].DownloadFileCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="5"
Content="Supprimer"
Command="{Binding $parent[vm:MyFilesViewModel].DeleteFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Interactivity;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class MyFilesPage : ContentPage
{
public MyFilesPage()
{
InitializeComponent();
}
private async void Upload_Click(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not MyFilesViewModel vm)
return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir des fichiers à téléverser dans Mes fichiers",
AllowMultiple = true,
});
if (files.Count == 0)
return;
var uploads = new List<Yavsc.Api.Client.UploadFile>(files.Count);
foreach (var file in files)
{
await using var stream = await file.OpenReadAsync();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
uploads.Add(new Yavsc.Api.Client.UploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
}
await vm.UploadAsync(uploads);
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}

View file

@ -8,7 +8,17 @@
public DateTime CreationTime { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModified { get; set; }
/// <summary>
/// Identifiant de la ligne <c>UploadedFile</c> associée, quand le
/// fichier a été téléversé via <c>api/v1/fs</c> (donc référencé en
/// base). <c>null</c> pour un fichier présent sur disque sans ligne
/// de métadonnées (fichier antérieur à la fonctionnalité, ou non
/// encore backfillé). C'est cette valeur que le client lourd
/// (PostIt) envoie pour attacher le fichier à un devis/demande.
/// </summary>
public long? Id { get; set; }
}

View 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);

View file

@ -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()

View file

@ -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()

View 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;
}
}

View file

@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
@ -237,6 +238,96 @@ namespace Yavsc.Controllers
base.Dispose(disposing);
}
// GET: api/v1/estimate/{id}/attachments
//
// Liste les fichiers de l'espace perso du fournisseur rattachés au
// devis, par référence (UploadedFile). Lecture ouverte aux deux
// parties (fournisseur/client) du devis et aux admin, comme
// GetEstimate : la contrepartie doit pouvoir voir et télécharger
// les pièces (le téléchargement des octets reste servi par le host
// Blogs, qui autorise via cette même table de jointure).
[HttpGet("{id}/attachments")]
public IActionResult GetAttachments(long id)
{
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
if (!UserIsAdminOrInThese(estimate.ClientId, estimate.OwnerId))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var attached = _context.EstimateAttachedFiles
.Where(a => a.EstimateId == id)
.Select(a => new
{
fileId = a.FileId,
path = a.File.Path,
size = a.File.Length,
contentType = a.File.ContentType,
ownerId = a.File.OwnerId
})
.ToList();
return Ok(attached);
}
// POST: api/v1/estimate/{id}/attachments { fileId }
//
// Attache un fichier de l'espace perso du fournisseur au devis, par
// référence. Réservé au fournisseur (estimate.OwnerId == caller) :
// seul le fournisseur constitue le devis et y joint ses documents
// (dont les documents à faire signer par le client, per README).
// Le fichier doit appartenir au fournisseur (UploadedFile.OwnerId).
[HttpPost("{id}/attachments")]
public IActionResult AttachFile(long id, [FromBody] AttachFileRequest body)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
return BadRequest(new { error = "fileId required" });
var file = _context.UploadedFiles.FirstOrDefault(f => f.Id == body.FileId);
if (file == null) return BadRequest(new { error = "file not found" });
if (file.OwnerId != uid)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var existing = _context.EstimateAttachedFiles
.FirstOrDefault(a => a.EstimateId == id && a.FileId == body.FileId);
if (existing != null) return Ok(new { estimateId = id, fileId = body.FileId });
_context.EstimateAttachedFiles.Add(new EstimateAttachedFile
{
EstimateId = id,
FileId = body.FileId
});
_context.SaveChanges(User.GetUserId());
return Ok(new { estimateId = id, fileId = body.FileId });
}
// DELETE: api/v1/estimate/{id}/attachments/{fileId}
//
// Détache un fichier du devis. Réservé au fournisseur.
[HttpDelete("{id}/attachments/{fileId}")]
public IActionResult DetachFile(long id, long fileId)
{
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = _context.EstimateAttachedFiles
.FirstOrDefault(a => a.EstimateId == id && a.FileId == fileId);
if (link == null) return NotFound();
_context.EstimateAttachedFiles.Remove(link);
_context.SaveChanges(User.GetUserId());
return Ok(new { estimateId = id, fileId });
}
private bool EstimateExists(long id)
{
return _context.Estimates.Count(e => e.Id == id) > 0;

View file

@ -8,6 +8,7 @@ using Yavsc.Abstract.Workflow;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice;
@ -366,6 +367,95 @@ namespace Yavsc.ApiControllers
private static bool HasSignature(QueryAcceptanceRequest? body)
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
// 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 });
}
}
internal enum ActorRole { None, Provider, Client, Admin }

View file

@ -8,10 +8,14 @@ namespace Yavsc.Blogs.Controllers
{
using Yavsc.Attributes.Validation;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Models.Billing;
using Yavsc.Exceptions;
using Yavsc.Server.Helpers;
using Yavsc.Abstract.Helpers;
using Yavsc.Server.Models.FileSystem;
using Yavsc.ViewModels.UserFiles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
[Authorize,Route(APIPrefix + "/fs")]
@ -34,21 +38,55 @@ namespace Yavsc.Blogs.Controllers
this.siteSettings = siteSettings.Value;
}
// GET: api/v1/fs
[HttpGet()]
public IActionResult Get()
{
return GetDir(null);
}
// GET: api/v1/fs/{subdir}
// Liste les fichiers du sous-répertoire personnel de l'utilisateur,
// en enrichissant chaque entrée de l'identifiant de métadonnées
// UploadedFile.Id quand une ligne existe (cf. POST ci-dessous).
[HttpGet("{*subdir}")]
public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="")
{
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
// _logger.LogInformation($"listing files from {User.Identity.Name}{subdir}");
var files = FileSystemHelpers.GetUserFiles(siteSettings, User.GetUserId(), subdir);
var uid = User.GetUserId();
var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir);
EnrichWithFileIds(files, uid, subdir);
return Ok(files);
}
// Reporte l'UploadedFile.Id (quand il existe) sur chaque entrée
// retournée par la liste, pour que le client lourd puisse attacher
// le fichier à un devis/demande par référence.
void EnrichWithFileIds(UserDirectoryInfo dir, string ownerId, string subdir)
{
if (dir?.Files == null || dir.Files.Length == 0) return;
var relPaths = dir.Files
.Select(f => RelPath(subdir, f.Name))
.ToHashSet();
var byPath = dbContext.UploadedFiles
.Where(u => u.OwnerId == ownerId && relPaths.Contains(u.Path))
.ToDictionary(u => u.Path, u => u.Id);
foreach (var f in dir.Files)
{
var rel = RelPath(subdir, f.Name);
if (byPath.TryGetValue(rel, out var id)) f.Id = id;
}
}
// Chemin relatif au racine de l'espace perso de l'utilisateur,
// tel que stocké dans UploadedFile.Path (sans segment utilisateur).
static string RelPath(string subdir, string fileName)
{
// Normalize with '/' for cross-platform consistency in DB.
if (string.IsNullOrWhiteSpace(subdir)) return fileName;
return $"{subdir.TrimStart('/')}/{fileName}";
}
[HttpPost("{*subdir}")]
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
{
@ -78,7 +116,38 @@ namespace Yavsc.Blogs.Controllers
foreach (var f in Request.Form.Files)
{
var item = user.ReceiveUserFile(destDir, f);
if (!item.QuotaOffense)
{
// Persiste une ligne de métadonnées UploadedFile,
// propriétaire = l'utilisateur courant, chemin relatif
// à son espace perso. Upsert sur (OwnerId, Path) pour
// le re-téléversement (même fichier écrasé sur disque).
var rel = RelPath(subdir, item.FileName);
var existing = dbContext.UploadedFiles
.FirstOrDefault(u => u.OwnerId == uid && u.Path == rel);
if (existing == null)
{
var uploaded = new UploadedFile
{
OwnerId = uid,
Path = rel,
ContentType = f.ContentType,
Length = f.Length
};
dbContext.UploadedFiles.Add(uploaded);
dbContext.SaveChanges(User.GetUserId());
item.FileId = uploaded.Id;
}
else
{
existing.ContentType = f.ContentType;
existing.Length = f.Length;
dbContext.SaveChanges(User.GetUserId());
item.FileId = existing.Id;
}
item.Length = f.Length;
item.ContentType = f.ContentType;
}
received.Add(item);
_logger.LogInformation($"Received '{item.FileName}'.");
if (item.QuotaOffense)
@ -88,6 +157,62 @@ namespace Yavsc.Blogs.Controllers
return Ok(received);
}
// GET: api/v1/fs/file/{fileId}
// Télécharge une pièce jointe référencée par son UploadedFile.Id.
// Autorise : le propriétaire du fichier, ou toute partie (client /
// fournisseur) d'un devis ou d'une demande auquel ce fichier est
// rattaché via EstimateAttachedFile / QueryAttachedFile. Les octets
// sont lus depuis l'espace perso du propriétaire, sur le disque de
// ce host Blogs (le host API ne possède pas ces octets).
[HttpGet("file/{fileId:long}")]
public IActionResult GetFile(long fileId)
{
var file = dbContext.UploadedFiles.FirstOrDefault(u => u.Id == fileId);
if (file == null) return NotFound(new { error = "file not found" });
var uid = User.GetUserId();
if (!CanReadFile(file, uid))
return Forbid();
var ownerUserName = dbContext.Users
.Where(u => u.Id == file.OwnerId)
.Select(u => u.UserName)
.FirstOrDefault();
if (string.IsNullOrEmpty(ownerUserName))
return NotFound(new { error = "owner not found" });
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
var fi = new FileInfo(physicalPath);
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
var contentType = string.IsNullOrWhiteSpace(file.ContentType)
? "application/octet-stream" : file.ContentType;
var stream = fi.OpenRead();
return File(stream, contentType, fi.Name);
}
// Le propriétaire peut toujours lire son fichier ; sinon, il faut
// que le fichier soit rattaché à un devis ou une demande dont
// l'appelant est partie (client ou fournisseur). Les admin sont
// autorisés comme pour le reste du workflow.
bool CanReadFile(UploadedFile file, string uid)
{
if (User.IsInRole(Constants.AdminGroupName)) return true;
if (file.OwnerId == uid) return true;
if (dbContext.EstimateAttachedFiles.Any(a =>
a.FileId == file.Id &&
(a.Estimate.ClientId == uid || a.Estimate.OwnerId == uid)))
return true;
if (dbContext.QueryAttachedFiles.Any(a =>
a.FileId == file.Id &&
(a.Command.ClientId == uid || a.Command.PerformerId == uid)))
return true;
return false;
}
[Route("/api/fsc/addquota/{uname}/{len}")]
[Authorize("AdministratorOnly")]
public IActionResult AddQuota(string uname, int len)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,148 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class UserFileAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Path",
table: "UploadedFiles",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadedFiles",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "OwnerId",
table: "UploadedFiles",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "EstimateAttachedFiles",
columns: table => new
{
FileId = table.Column<long>(type: "bigint", nullable: false),
EstimateId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EstimateAttachedFiles", x => new { x.FileId, x.EstimateId });
table.ForeignKey(
name: "FK_EstimateAttachedFiles_Estimates_EstimateId",
column: x => x.EstimateId,
principalTable: "Estimates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EstimateAttachedFiles_UploadedFiles_FileId",
column: x => x.FileId,
principalTable: "UploadedFiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "QueryAttachedFiles",
columns: table => new
{
FileId = table.Column<long>(type: "bigint", nullable: false),
CommandId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryAttachedFiles", x => new { x.FileId, x.CommandId });
table.ForeignKey(
name: "FK_QueryAttachedFiles_NominativeServiceCommands_CommandId",
column: x => x.CommandId,
principalTable: "NominativeServiceCommands",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_QueryAttachedFiles_UploadedFiles_FileId",
column: x => x.FileId,
principalTable: "UploadedFiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_OwnerId",
table: "UploadedFiles",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_EstimateAttachedFiles_EstimateId",
table: "EstimateAttachedFiles",
column: "EstimateId");
migrationBuilder.CreateIndex(
name: "IX_QueryAttachedFiles_CommandId",
table: "QueryAttachedFiles",
column: "CommandId");
migrationBuilder.AddForeignKey(
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
table: "UploadedFiles",
column: "OwnerId",
principalTable: "AspNetUsers",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
table: "UploadedFiles");
migrationBuilder.DropTable(
name: "EstimateAttachedFiles");
migrationBuilder.DropTable(
name: "QueryAttachedFiles");
migrationBuilder.DropIndex(
name: "IX_UploadedFiles_OwnerId",
table: "UploadedFiles");
migrationBuilder.DropColumn(
name: "OwnerId",
table: "UploadedFiles");
migrationBuilder.AlterColumn<string>(
name: "Path",
table: "UploadedFiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadedFiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
}
}

View file

@ -1394,6 +1394,21 @@ namespace Yavsc.Migrations
b.ToTable("Estimates");
});
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
{
b.Property<long>("FileId")
.HasColumnType("bigint");
b.Property<long>("EstimateId")
.HasColumnType("bigint");
b.HasKey("FileId", "EstimateId");
b.HasIndex("EstimateId");
b.ToTable("EstimateAttachedFiles");
});
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
{
b.Property<long>("Id")
@ -1502,6 +1517,21 @@ namespace Yavsc.Migrations
b.UseTphMappingStrategy();
});
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
{
b.Property<long>("FileId")
.HasColumnType("bigint");
b.Property<long>("CommandId")
.HasColumnType("bigint");
b.HasKey("FileId", "CommandId");
b.HasIndex("CommandId");
b.ToTable("QueryAttachedFiles");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{
b.Property<long>("Id")
@ -1674,16 +1704,23 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<long>("Length")
.HasColumnType("bigint");
b.Property<string>("OwnerId")
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("UploadedFiles");
});
@ -3827,6 +3864,25 @@ namespace Yavsc.Migrations
b.Navigation("Query");
});
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
.WithMany()
.HasForeignKey("EstimateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Estimate");
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
@ -3860,6 +3916,25 @@ namespace Yavsc.Migrations
b.Navigation("Regularization");
});
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
.WithMany()
.HasForeignKey("CommandId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Command");
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
@ -3952,6 +4027,15 @@ namespace Yavsc.Migrations
b.Navigation("Post");
});
modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b =>
{
b.HasOne("Yavsc.Models.ApplicationUser", "Owner")
.WithMany()
.HasForeignKey("OwnerId");
b.Navigation("Owner");
});
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
{
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")

View file

@ -509,5 +509,9 @@ namespace Yavsc.Models
public DbSet<ModerationLog> ModerationLogs { get; set; }
public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; }
public DbSet<EstimateAttachedFile> EstimateAttachedFiles { get; set; }
public DbSet<QueryAttachedFile> QueryAttachedFiles { get; set; }
}
}

View file

@ -0,0 +1,13 @@
namespace Yavsc.Models.Billing
{
/// <summary>
/// Corps de requête pour attacher un fichier de l'espace perso à un
/// devis (fournisseur) ou à une demande (client), par référence à son
/// identifiant <see cref="Yavsc.Models.Blog.UploadedFile"/>.
/// </summary>
public class AttachFileRequest
{
/// <summary>Identifiant du fichier référencé.</summary>
public long FileId { get; set; }
}
}

View file

@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
namespace Yavsc.Models.Billing
{
/// <summary>
/// Pièce jointe d'un devis, attachée **par référence** à un fichier
/// de l'espace de stockage personnel du fournisseur
/// (<see cref="UploadedFile"/>). Le fichier reste dans l'espace perso
/// du fournisseur (pas de duplication) ; seul le lien est persisté.
/// Le téléchargement est servi par le host Blogs, qui autorise
/// l'appelant (fournisseur ou client du devis) via cette table.
/// </summary>
[PrimaryKey(nameof(FileId), nameof(EstimateId))]
public class EstimateAttachedFile
{
/// <summary>
/// Identifiant du fichier référencé (partie de la clé composite).
/// </summary>
public long FileId { get; set; }
[ForeignKey("FileId")]
public virtual UploadedFile File { get; set; }
/// <summary>
/// Identifiant du devis auquel le fichier est rattaché
/// (partie de la clé composite).
/// </summary>
public long EstimateId { get; set; }
[ForeignKey("EstimateId")]
public virtual Estimate Estimate { get; set; }
}
}

View file

@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
namespace Yavsc.Models.Billing
{
/// <summary>
/// Pièce jointe d'une demande (NominativeServiceCommand), attachée
/// **par référence** à un fichier de l'espace de stockage personnel
/// du client (<see cref="UploadedFile"/>). Le fichier reste dans
/// l'espace perso du client (pas de duplication) ; seul le lien est
/// persisté. Le téléchargement est servi par le host Blogs, qui
/// autorise l'appelant (client ou fournisseur de la demande) via
/// cette table.
/// </summary>
[PrimaryKey(nameof(FileId), nameof(CommandId))]
public class QueryAttachedFile
{
/// <summary>
/// Identifiant du fichier référencé (partie de la clé composite).
/// </summary>
public long FileId { get; set; }
[ForeignKey("FileId")]
public virtual UploadedFile File { get; set; }
/// <summary>
/// Identifiant de la demande à laquelle le fichier est rattaché
/// (partie de la clé composite).
/// </summary>
public long CommandId { get; set; }
[ForeignKey("CommandId")]
public virtual NominativeServiceCommand Command { get; set; }
}
}

View file

@ -1,3 +1,4 @@
#nullable enable annotations
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
@ -33,6 +34,19 @@ namespace Yavsc.Models.Blog
/// </summary>
/// <value></value>
public string ContentType { get; set; }
/// <summary>
/// Propriétaire du fichier dans l'espace de stockage personnel.
/// Renseigné pour les fichiers téléversés via <c>api/v1/fs</c>
/// (référencés par <see cref="EstimateAttachedFile"/> /
/// <see cref="QueryAttachedFile"/>). Reste <c>null</c> pour les
/// pièces jointes de blog historiques (<see cref="BlogAttachedFile"/>),
/// qui ne sont pas adressées par propriétaire.
/// </summary>
public string? OwnerId { get; set; }
[ForeignKey("OwnerId")]
public virtual ApplicationUser? Owner { get; set; }
}
[PrimaryKey(nameof(FileId), nameof(PostId))]

View file

@ -48,6 +48,24 @@ namespace Yavsc.Server.Models.FileSystem
public bool Overridden { get; set; }
public bool QuotaOffense { get; set; }
/// <summary>
/// Identifiant de la ligne <see cref="Yavsc.Models.Blog.UploadedFile"/>
/// créée à l'enregistrement du fichier dans l'espace perso
/// (<c>api/v1/fs</c>). Nul si aucune métadonnée n'a été persistée.
/// </summary>
public long? FileId { get; set; }
/// <summary>
/// Taille du fichier reçu (octets), pour le reflet côté métadonnées.
/// </summary>
public long Length { get; set; }
/// <summary>
/// Type MIME du fichier reçu.
/// </summary>
public string ContentType { get; set; }
public string FullName { get => Path.Combine(DestDir, FileName); }
}