From 70ab05522bad0a930861c5e331c48537a2dec8aa Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 24 Sep 2026 16:35:36 +0100 Subject: [PATCH 1/3] =?UTF-8?q?Gestion=20des=20fichiers=20utilisateur=20et?= =?UTF-8?q?=20pi=C3=A8ces=20jointes=20(demande/devis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../EstimateEditionPageViewModelTests.cs | 33 +- .../Helpers/ServiceCollectionHelpers.cs | 3 + src/PostIt/PostIt/ViewLocator.cs | 1 + .../Activity/BillingQueriesPageViewModel.cs | 6 +- .../BillingQueryDetailsPageViewModel.cs | 127 +- .../Activity/EstimateEditionPageViewModel.cs | 120 +- .../ProviderOngoingRequestsPageViewModel.cs | 14 +- .../ViewModels/Layout/HomePageViewModel.cs | 25 + .../ViewModels/MyFiles/MyFilesViewModel.cs | 229 + .../Activity/BillingQueryDetailsPage.axaml | 48 + .../Views/Activity/EstimateEditionPage.axaml | 47 + src/PostIt/PostIt/Views/Layout/HomePage.axaml | 4 + .../PostIt/Views/MyFiles/MyFilesPage.axaml | 90 + .../PostIt/Views/MyFiles/MyFilesPage.axaml.cs | 58 + .../FileSystem/RemoteFileInfo.cs | 12 +- src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs | 66 + src/Yavsc.Api.Client/EstimateApiClient.cs | 35 + src/Yavsc.Api.Client/FrontOfficeApiClient.cs | 42 + src/Yavsc.Api.Client/UserFilesApiClient.cs | 112 + .../Business/EstimateApiController.cs | 91 + .../Business/FrontOfficeApiController.cs | 90 + .../Controllers/FileSystemApiController.cs | 131 +- ...0924150433_UserFileAttachments.Designer.cs | 4797 +++++++++++++++++ .../20260924150433_UserFileAttachments.cs | 148 + .../ApplicationDbContextModelSnapshot.cs | 84 + .../Models/ApplicationDbContext.cs | 4 + .../Models/Billing/AttachFileRequest.cs | 13 + .../Models/Billing/EstimateAttachedFile.cs | 35 + .../Models/Billing/QueryAttachedFile.cs | 36 + .../Models/Blog/BlogAttachedFile.cs | 14 + .../Models/FileSystem/FileReceivedInfo.cs | 20 +- 31 files changed, 6515 insertions(+), 20 deletions(-) create mode 100644 src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs create mode 100644 src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml create mode 100644 src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml.cs create mode 100644 src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs create mode 100644 src/Yavsc.Api.Client/UserFilesApiClient.cs create mode 100644 src/Yavsc.Org/Migrations/20260924150433_UserFileAttachments.Designer.cs create mode 100644 src/Yavsc.Org/Migrations/20260924150433_UserFileAttachments.cs create mode 100644 src/Yavsc.Server/Models/Billing/AttachFileRequest.cs create mode 100644 src/Yavsc.Server/Models/Billing/EstimateAttachedFile.cs create mode 100644 src/Yavsc.Server/Models/Billing/QueryAttachedFile.cs diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index db70e25fa..17efd391b 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -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(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(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(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(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 CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + /// Historique complet des appels (le plus récent en dernier). + 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 CallAsync(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 contentFactory, CancellationToken ct = default) => CallAsync(method, path, (object?)null, ct); + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + Record(method, path, null); + return Task.FromResult(Array.Empty()); + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 2b243f2d5..9616e7089 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -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(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // 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(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index ca9a7ff65..687a7cbd2 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -56,6 +56,7 @@ public class ViewLocator : IDataTemplate EstimateEditionPageViewModel => services.GetRequiredService(), EstimateListPageViewModel => services.GetRequiredService(), EstimateValidationPageViewModel => services.GetRequiredService(), + MyFilesViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs index dbd84e5c2..f1b05b749 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -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(); + var fsClient = app.ServiceProvider?.GetRequiredService(); 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) diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs index 9ab1b42fd..c9f122dce 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs @@ -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; + /// + /// 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é EstimateEditionPage). + /// + 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."); + /// Pièces jointes rattachées à la demande (par référence). + [ObservableProperty] + public partial ObservableCollection 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."); } + /// Constructeur pour le designer Avalonia. + 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); } + /// Charge les pièces jointes de la demande. + 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}"); + } + } + + /// + /// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi + /// est rattaché à la demande par référence via + /// POST front/query/{id}/attachments. Réservé au client + /// (). + /// + [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; + } + } + /// /// Download the estimate linked to this command as a LaTeX source /// (GET api/v1/front/query/{Id}/estimate.tex). The estimate is diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs index 8f007dc08..1f1e4b4be 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs @@ -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."); + /// Identifiant du devis, renseigné après l'envoi. + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))] + public partial long? EstimateId { get; set; } + + /// Pièces jointes rattachées au devis (par référence). + [ObservableProperty] + public partial ObservableCollection 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."); } + /// Constructeur pour le designer Avalonia. + 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); } + /// + /// Charge les pièces jointes rattachées au devis courant + /// (GET estimate/{id}/attachments). + /// + [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; + + /// + /// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi + /// est rattaché au devis par référence via + /// POST estimate/{id}/attachments. + /// + [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 diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs index 75a5bdc9a..7fd84fd88 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -155,6 +155,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi .ConfigureAwait(true); var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var frontClient = app.ServiceProvider?.GetRequiredService(); + var fsClient = app.ServiceProvider?.GetRequiredService(); 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(); + if (fsClient is null) + { + throw new InvalidOperationException("Client fichiers indisponible."); + } + + var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient); await app.PushPageAsync(vm).ConfigureAwait(true); } diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 9d466b26c..2c138a27d 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -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); } + /// + /// Ouvre la page « Mes fichiers » (espace de stockage personnel), + /// servie par le host Blogs via api/v1/fs. + /// + private async Task OpenMyFilesAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var fsClient = app.ServiceProvider?.GetRequiredService(); + if (fsClient is null) + { + throw new InvalidOperationException("Client fichiers indisponible."); + } + + var vm = new MyFilesViewModel(fsClient); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + /// /// Avalonia designer constructor. Builds a self-contained VM /// with a freshly-constructed Settings so the XAML preview can diff --git a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs new file mode 100644 index 000000000..cc4b46828 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs @@ -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; + +/// +/// 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 +/// est vrai, la sélection d'un fichier invoque +/// avec l'identifiant +/// puis revient à la page précédente. +/// +public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly UserFilesApiClient _fs; + private readonly Action? _onFilePicked; + + /// + /// Sous-répertoire courant, relatif à la racine de l'espace perso + /// (chaîne vide à la racine). Servi tel quel à GET/POST fs/{subdir}. + /// + [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 Files { get; set; } = new(); + + [ObservableProperty] + public partial ObservableCollection 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? onFilePicked = null) + { + _fs = fs ?? throw new ArgumentNullException(nameof(fs)); + _onFilePicked = onFilePicked; + } + + /// Constructeur pour le designer Avalonia. + 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); + } + + /// Téléversement déclenché par le code-behind (picker OS). + public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection 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]; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml index 904f17350..4cbce9a20 100644 --- a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml @@ -1,6 +1,7 @@ + + + + + + /// public string ContentType { get; set; } + + /// + /// Propriétaire du fichier dans l'espace de stockage personnel. + /// Renseigné pour les fichiers téléversés via api/v1/fs + /// (référencés par / + /// ). Reste null pour les + /// pièces jointes de blog historiques (), + /// qui ne sont pas adressées par propriétaire. + /// + public string? OwnerId { get; set; } + + [ForeignKey("OwnerId")] + public virtual ApplicationUser? Owner { get; set; } } [PrimaryKey(nameof(FileId), nameof(PostId))] diff --git a/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs b/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs index 63627c116..602cc4bf0 100644 --- a/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs +++ b/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs @@ -43,11 +43,29 @@ namespace Yavsc.Server.Models.FileSystem } public string DestDir { get; set; } - + public string FileName { get; set; } public bool Overridden { get; set; } public bool QuotaOffense { get; set; } + + /// + /// Identifiant de la ligne + /// créée à l'enregistrement du fichier dans l'espace perso + /// (api/v1/fs). Nul si aucune métadonnée n'a été persistée. + /// + public long? FileId { get; set; } + + /// + /// Taille du fichier reçu (octets), pour le reflet côté métadonnées. + /// + public long Length { get; set; } + + /// + /// Type MIME du fichier reçu. + /// + public string ContentType { get; set; } + public string FullName { get => Path.Combine(DestDir, FileName); } } From 8e3862ed26d1c6c831bd531f8ac2d9cd3e86f38f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 24 Sep 2026 17:14:47 +0100 Subject: [PATCH 2/3] Tests d'attachement (devis/demande) et de l'espace de stockage perso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couverture des endpoints backend introduits pour la gestion des fichiers utilisateur et des pièces jointes par référence. Yavsc.Api.Test: - EstimateAttachmentsApiTests : attachement de fichiers au devis (fournisseur uniquement, fichier devant appartenir au caller, idempotence du re-attach, détachement, listage ouvert aux deux parties + admin, tiers refusé, 404 sur devis inconnu). - FrontOfficeQueryAttachmentsApiTests : attachement à la demande (client uniquement, fichier devant appartenir au caller, détachement, listage ouvert aux deux parties, tiers refusé). Yavsc.Blogs.Tests: - FileSystemApiTests : POST api/v1/fs crée une ligne UploadedFile propriétaire (avec FileId en retour) et écrit le fichier sur disque ; GET api/v1/fs/file/{fileId} autorise le propriétaire et une partie au devis rattaché (client/fournisseur), refuse un tiers, 404 sur id inconnu. 306/306 verts. Co-Authored-By: Claude Code --- .../EstimateAttachmentsApiTests.cs | 303 ++++++++++++++++++ .../FrontOfficeQueryAttachmentsApiTests.cs | 256 +++++++++++++++ src/Yavsc.Blogs.Tests/FileSystemApiTests.cs | 294 +++++++++++++++++ 3 files changed, 853 insertions(+) create mode 100644 src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs create mode 100644 src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs create mode 100644 src/Yavsc.Blogs.Tests/FileSystemApiTests.cs diff --git a/src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs b/src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs new file mode 100644 index 000000000..226b8e72a --- /dev/null +++ b/src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs @@ -0,0 +1,303 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Api.Test.Fixtures; +using Yavsc.Models; +using Yavsc.Models.Billing; +using Yavsc.Models.Blog; +using Yavsc.Tests.Shared; + +namespace Yavsc.Api.Test; + +/// +/// Integration tests for the estimate attachment endpoints +/// (GET/POST/DELETE api/v1/estimate/{id}/attachments), which +/// attach a file from the provider's personal storage to an estimate +/// **by reference** (). +/// +/// Coverage mirrors the authorization contract documented on the +/// controller: only the provider (estimate.OwnerId) may attach or +/// detach, the file must belong to the caller, and listing is open to +/// both parties (provider/client) plus admin. A third party is refused. +/// The download of the bytes themselves is served by the Blogs host and +/// exercised in Yavsc.Blogs.Tests; these tests cover only the +/// metadata/relationship endpoints that live on the API host. +/// +[Collection("Yavsc Api")] +public sealed class EstimateAttachmentsApiTests : IClassFixture +{ + private readonly ApiWebServerFixture _fixture; + + public EstimateAttachmentsApiTests(ApiWebServerFixture fixture) + { + _fixture = fixture; + } + + private HttpClient NewClient(string subject = "alice", string scope = "api") + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + var http = new HttpClient(handler) + { + BaseAddress = new Uri(_fixture.BaseAddress) + }; + http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope)); + return http; + } + + /// + /// Reset the graph (seeding alice the provider and bob the client), + /// create an estimate owned by alice for bob, and seed two personal + /// rows: one owned by alice (the provider's + /// own file) and one owned by bob (a file that does NOT belong to the + /// provider, used to assert the ownership guard on attach). + /// + private (long estimateId, long aliceFileId, long bobFileId) Seed() + { + _fixture.ResetAndSeedActivityGraph(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var estimate = new Estimate + { + ClientId = "bob", + OwnerId = "alice", + CommandType = BillingCodes.Rdv, + Title = "Devis prestation", + Description = "Devis de test", + AttachedFiles = new List(), + AttachedGraphics = new List(), + Bill = new List + { + new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" }, + }, + }; + db.Estimates.Add(estimate); + db.SaveChanges(); + + var aliceFile = new UploadedFile + { + OwnerId = "alice", + Path = "provider-doc.pdf", + ContentType = "application/pdf", + Length = 2048, + }; + var bobFile = new UploadedFile + { + OwnerId = "bob", + Path = "client-note.txt", + ContentType = "text/plain", + Length = 64, + }; + db.UploadedFiles.AddRange(aliceFile, bobFile); + db.SaveChanges(); + + return (estimate.Id, aliceFile.Id, bobFile.Id); + } + + private int CountAttachments(long estimateId) + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.EstimateAttachedFiles.Count(a => a.EstimateId == estimateId); + } + + [Fact] + public async Task Provider_attaching_own_file_creates_the_link() + { + var (estimateId, aliceFileId, _) = Seed(); + + using var http = NewClient("alice"); + var response = await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + Assert.Equal(1, CountAttachments(estimateId)); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var link = Assert.Single(db.EstimateAttachedFiles.Where(a => a.EstimateId == estimateId)); + Assert.Equal(aliceFileId, link.FileId); + } + + [Fact] + public async Task Attaching_the_same_file_twice_is_idempotent() + { + var (estimateId, aliceFileId, _) = Seed(); + + using var http = NewClient("alice"); + await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + var second = await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(1, CountAttachments(estimateId)); + } + + [Fact] + public async Task Non_provider_cannot_attach_to_an_estimate() + { + var (estimateId, aliceFileId, _) = Seed(); + + // bob is the client of the estimate, not the provider. + using var http = NewClient("bob"); + var response = await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(0, CountAttachments(estimateId)); + } + + [Fact] + public async Task Provider_cannot_attach_a_file_they_do_not_own() + { + var (estimateId, _, bobFileId) = Seed(); + + // alice is the provider, but the file belongs to bob. + using var http = NewClient("alice"); + var response = await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = bobFileId }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(0, CountAttachments(estimateId)); + } + + [Fact] + public async Task Provider_detaches_a_file() + { + var (estimateId, aliceFileId, _) = Seed(); + + using var http = NewClient("alice"); + await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + Assert.Equal(1, CountAttachments(estimateId)); + + var response = await http.DeleteAsync( + $"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(0, CountAttachments(estimateId)); + } + + [Fact] + public async Task Non_provider_cannot_detach() + { + var (estimateId, aliceFileId, _) = Seed(); + + using var http = NewClient("alice"); + await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + + using var bob = NewClient("bob"); + var response = await bob.DeleteAsync( + $"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(1, CountAttachments(estimateId)); + } + + [Fact] + public async Task Provider_lists_attachments() + { + var (estimateId, aliceFileId, _) = Seed(); + + using var http = NewClient("alice"); + await http.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + + var response = await http.GetAsync( + $"/api/v1/estimate/{estimateId}/attachments", + TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + using var doc = JsonDocument.Parse(body); + var item = Assert.Single(doc.RootElement.EnumerateArray()); + Assert.Equal(aliceFileId, item.GetProperty("fileId").GetInt64()); + Assert.Equal("provider-doc.pdf", item.GetProperty("path").GetString()); + Assert.Equal("alice", item.GetProperty("ownerId").GetString()); + } + + [Fact] + public async Task Client_can_list_attachments_as_a_party() + { + var (estimateId, aliceFileId, _) = Seed(); + + using var alice = NewClient("alice"); + await alice.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + + // bob is the client of the estimate: a party, may list. + using var bob = NewClient("bob"); + var response = await bob.GetAsync( + $"/api/v1/estimate/{estimateId}/attachments", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Third_party_cannot_list_attachments() + { + var (estimateId, aliceFileId, _) = Seed(); + + using var alice = NewClient("alice"); + await alice.PostAsJsonAsync( + $"/api/v1/estimate/{estimateId}/attachments", + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + + // carol is neither the provider nor the client. + using var carol = NewClient("carol"); + var response = await carol.GetAsync( + $"/api/v1/estimate/{estimateId}/attachments", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Listing_an_unknown_estimate_returns_404() + { + _fixture.ResetAndSeedActivityGraph(); + using var http = NewClient("alice"); + + var response = await http.GetAsync( + "/api/v1/estimate/999999/attachments", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} \ No newline at end of file diff --git a/src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs b/src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs new file mode 100644 index 000000000..d4438ef4e --- /dev/null +++ b/src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs @@ -0,0 +1,256 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Api.Test.Fixtures; +using Yavsc.Helpers; +using Yavsc.Models; +using Yavsc.Models.Billing; +using Yavsc.Models.Blog; +using Yavsc.Models.Workflow; +using Yavsc.Tests.Shared; + +namespace Yavsc.Api.Test; + +/// +/// Integration tests for the query (demande) attachment endpoints +/// (GET/POST/DELETE api/v1/front/query/{queryId}/attachments), +/// which attach a file from the client's personal storage to their +/// NominativeServiceCommand **by reference** +/// (). +/// +/// Coverage mirrors the controller contract: only the client +/// (the demande's ClientId) may attach or detach, the file must +/// belong to the caller, and listing is open to both parties +/// (client/provider) plus admin. A third party is refused. +/// +[Collection("Yavsc Api")] +public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture +{ + private readonly ApiWebServerFixture _fixture; + + public FrontOfficeQueryAttachmentsApiTests(ApiWebServerFixture fixture) + { + _fixture = fixture; + } + + private HttpClient NewClient(string subject = "bob", string scope = "api") + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + var http = new HttpClient(handler) + { + BaseAddress = new Uri(_fixture.BaseAddress) + }; + http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope)); + return http; + } + + /// + /// Seed an Rdv query from bob (client) to alice (provider) and two + /// personal rows: one owned by bob (the + /// client's own file) and one owned by alice (a file that does NOT + /// belong to the client, used to assert the ownership guard). + /// + private (long queryId, long bobFileId, long aliceFileId) Seed() + { + WorkflowHelpers.ConfigureBillingService(); + _fixture.ResetAndSeedActivityGraph(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var location = db.Locations.Single(l => l.Address == "1 rue du Test"); + + var query = new RdvQuery + { + ActivityCode = "dev", + ClientId = "bob", + PerformerId = "alice", + Consent = true, + UserCreated = "bob", + UserModified = "bob", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + EventDate = DateTime.UtcNow.AddDays(1), + Location = location, + Reason = "Demande de devis", + Status = QueryStatus.InProgress, + Description = "Demande en attente", + }; + db.RdvQueries.Add(query); + db.SaveChanges(); + + var bobFile = new UploadedFile + { + OwnerId = "bob", + Path = "demande-photo.jpg", + ContentType = "image/jpeg", + Length = 4096, + }; + var aliceFile = new UploadedFile + { + OwnerId = "alice", + Path = "provider-spec.pdf", + ContentType = "application/pdf", + Length = 1024, + }; + db.UploadedFiles.AddRange(bobFile, aliceFile); + db.SaveChanges(); + + return (query.Id, bobFile.Id, aliceFile.Id); + } + + private int CountAttachments(long queryId) + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.QueryAttachedFiles.Count(a => a.CommandId == queryId); + } + + private string AttachUrl(long queryId) => + $"/api/v1/front/query/{queryId}/attachments?billingCode=Rdv"; + + private string DetachUrl(long queryId, long fileId) => + $"/api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=Rdv"; + + [Fact] + public async Task Client_attaching_own_file_creates_the_link() + { + var (queryId, bobFileId, _) = Seed(); + + using var http = NewClient("bob"); + var response = await http.PostAsJsonAsync( + AttachUrl(queryId), + new { fileId = bobFileId }, + TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + Assert.Equal(1, CountAttachments(queryId)); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var link = Assert.Single(db.QueryAttachedFiles.Where(a => a.CommandId == queryId)); + Assert.Equal(bobFileId, link.FileId); + } + + [Fact] + public async Task Provider_cannot_attach_to_a_query() + { + var (queryId, bobFileId, _) = Seed(); + + // alice is the provider of the query; only the client attaches. + using var http = NewClient("alice"); + var response = await http.PostAsJsonAsync( + AttachUrl(queryId), + new { fileId = bobFileId }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(0, CountAttachments(queryId)); + } + + [Fact] + public async Task Client_cannot_attach_a_file_they_do_not_own() + { + var (queryId, _, aliceFileId) = Seed(); + + // bob is the client, but the file belongs to alice. + using var http = NewClient("bob"); + var response = await http.PostAsJsonAsync( + AttachUrl(queryId), + new { fileId = aliceFileId }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(0, CountAttachments(queryId)); + } + + [Fact] + public async Task Client_detaches_a_file() + { + var (queryId, bobFileId, _) = Seed(); + + using var http = NewClient("bob"); + await http.PostAsJsonAsync( + AttachUrl(queryId), + new { fileId = bobFileId }, + TestContext.Current.CancellationToken); + Assert.Equal(1, CountAttachments(queryId)); + + var response = await http.DeleteAsync( + DetachUrl(queryId, bobFileId), + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(0, CountAttachments(queryId)); + } + + [Fact] + public async Task Provider_cannot_detach() + { + var (queryId, bobFileId, _) = Seed(); + + using var bob = NewClient("bob"); + await bob.PostAsJsonAsync( + AttachUrl(queryId), + new { fileId = bobFileId }, + TestContext.Current.CancellationToken); + + using var alice = NewClient("alice"); + var response = await alice.DeleteAsync( + DetachUrl(queryId, bobFileId), + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(1, CountAttachments(queryId)); + } + + [Fact] + public async Task Provider_lists_attachments_as_a_party() + { + var (queryId, bobFileId, _) = Seed(); + + using var bob = NewClient("bob"); + await bob.PostAsJsonAsync( + AttachUrl(queryId), + new { fileId = bobFileId }, + TestContext.Current.CancellationToken); + + // alice is the provider: a party, may list. + using var alice = NewClient("alice"); + var response = await alice.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + using var doc = JsonDocument.Parse(body); + var item = Assert.Single(doc.RootElement.EnumerateArray()); + Assert.Equal(bobFileId, item.GetProperty("fileId").GetInt64()); + Assert.Equal("bob", item.GetProperty("ownerId").GetString()); + } + + [Fact] + public async Task Third_party_cannot_list_attachments() + { + var (queryId, bobFileId, _) = Seed(); + + using var bob = NewClient("bob"); + await bob.PostAsJsonAsync( + AttachUrl(queryId), + new { fileId = bobFileId }, + TestContext.Current.CancellationToken); + + using var carol = NewClient("carol"); + var response = await carol.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} \ No newline at end of file diff --git a/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs b/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs new file mode 100644 index 000000000..b5eeb8e00 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs @@ -0,0 +1,294 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Blogs.Tests.Fixtures; +using Yavsc.Models; +using Yavsc.Models.Billing; +using Yavsc.Models.Blog; +using Yavsc.Models.Relationship; +using Yavsc.Models.Workflow; +using Yavsc.Tests.Shared; + +namespace Yavsc.Blogs.Tests; + +/// +/// Integration tests for the personal-storage file endpoints +/// (api/v1/fs) served by FileSystemApiController on the +/// Blogs host. +/// +/// Two concerns: +/// +/// Upload records metadata: a multipart +/// POST api/v1/fs persists an row +/// owned by the caller and returns its fileId, so the client +/// can later attach that file to an estimate/query by +/// reference. +/// Download authorization: +/// GET api/v1/fs/file/{fileId} serves the bytes from the +/// owner's personal space. The owner may always read; a party +/// (client or provider) of an estimate/query the file is attached +/// to may read; a third party is refused. The bytes live on the +/// Blogs host's disk — this is the cross-container reason the +/// download endpoint lives here and not on the API host. +/// +/// +[Collection("Yavsc Blogs")] +public sealed class FileSystemApiTests : IClassFixture +{ + private readonly BlogsWebServerFixture _fixture; + + public FileSystemApiTests(BlogsWebServerFixture fixture) + { + _fixture = fixture; + } + + private HttpClient NewClient(string subject = "alice") + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + var http = new HttpClient(handler) + { + BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) + }; + http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject)); + return http; + } + + private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) => + Path.Combine(fixture.BlogFilesRoot, owner, fileName); + + /// Write a physical file into the owner's personal space, + /// so GetFile can stream it back. Creates the directory if + /// needed. + private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content) + { + var path = PhysicalFile(fixture, owner, fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, content); + } + + private long SeedUploadedFile(string ownerId, string path, string contentType, long length) + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var file = new UploadedFile + { + OwnerId = ownerId, + Path = path, + ContentType = contentType, + Length = length, + }; + db.UploadedFiles.Add(file); + db.SaveChanges(); + return file.Id; + } + + [Fact] + public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller() + { + _fixture.ResetDatabase(); + // DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense + // once usage >= DiskQuota, and the controller skips the metadata + // row when QuotaOffense is set. A fresh user has quota 0. + _fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000); + + var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage"); + + using var form = new MultipartFormDataContent(); + var fileContent = new ByteArrayContent(payload); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + form.Add(fileContent, "file", "note.txt"); + + using var http = NewClient("alice"); + var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + using var doc = JsonDocument.Parse(body); + var item = Assert.Single(doc.RootElement.EnumerateArray()); + var fileId = item.GetProperty("fileId").GetInt64(); + Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}"); + Assert.Equal("note.txt", item.GetProperty("fileName").GetString()); + Assert.False(item.GetProperty("quotaOffense").GetBoolean()); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice")); + Assert.Equal("note.txt", row.Path); + Assert.Equal("text/plain", row.ContentType); + Assert.Equal(payload.Length, row.Length); + + // The file physically landed under {BlogFilesRoot}/alice/note.txt. + Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")), + "Expected the uploaded file on disk under the owner's personal root"); + } + + [Fact] + public async Task GetFile_owner_can_download_their_own_file() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + + var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload"); + WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes); + var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length); + + using var http = NewClient("alice"); + var response = await http.GetAsync( + $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(bytes, downloaded); + } + + [Fact] + public async Task GetFile_refuses_a_third_party() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + _fixture.SeedUser("carol"); + + var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice"); + WritePhysicalFile(_fixture, "alice", "secret.txt", bytes); + var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length); + + // carol is neither the owner nor a party to any estimate/query + // this file is attached to. + using var http = NewClient("carol"); + var response = await http.GetAsync( + $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + /// + /// Seed the graph needed to prove a party (the client of an + /// estimate the file is attached to) can download a file they do + /// not own: alice owns the file and is the provider; bob is the + /// client; the file is attached to the estimate by reference. + /// + private long SeedPartyDownloadGraph(out long estimateId, out string fileName) + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + _fixture.SeedUser("bob"); + + fileName = "party-spec.pdf"; + var bytes = System.Text.Encoding.UTF8.GetBytes("party payload"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Location + PerformerProfile(alice): Estimate.OwnerId is a FK + // to PerformerProfile.PerformerId, so the provider profile must + // exist before the estimate can be inserted. + var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 }; + db.Locations.Add(location); + db.SaveChanges(); + + db.Performers.Add(new PerformerProfile + { + PerformerId = "alice", + SIREN = "12345678901234", + OrganizationAddressId = location.Id, + ExerciseCountryCode = "fr", + AcceptNotifications = true, + AcceptPublicContact = true, + Active = true, + Rate = 5, + WebSite = "https://alice.dev", + }); + db.SaveChanges(); + + var estimate = new Estimate + { + OwnerId = "alice", + ClientId = "bob", + CommandType = "Rdv", + Title = "Devis party test", + Description = "Devis", + AttachedFiles = new List(), + AttachedGraphics = new List(), + Bill = new List + { + new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" }, + }, + }; + db.Estimates.Add(estimate); + db.SaveChanges(); + estimateId = estimate.Id; + + var file = new UploadedFile + { + OwnerId = "alice", + Path = fileName, + ContentType = "application/pdf", + Length = bytes.Length, + }; + db.UploadedFiles.Add(file); + db.SaveChanges(); + + db.EstimateAttachedFiles.Add(new EstimateAttachedFile + { + FileId = file.Id, + EstimateId = estimate.Id, + }); + db.SaveChanges(); + + // The bytes must exist on disk for GetFile to stream them. + WritePhysicalFile(_fixture, "alice", fileName, bytes); + return file.Id; + } + + [Fact] + public async Task GetFile_party_client_of_an_attached_estimate_can_download() + { + var fileId = SeedPartyDownloadGraph(out _, out _); + + // bob is the client of the estimate the file is attached to: + // a party, authorized to read even though alice owns the file. + using var http = NewClient("bob"); + var response = await http.GetAsync( + $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}"); + var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded); + } + + [Fact] + public async Task GetFile_party_provider_of_an_attached_estimate_can_download() + { + var fileId = SeedPartyDownloadGraph(out _, out _); + + // alice is the owner too, so this also exercises the owner path; + // the provider of an attached estimate is authorized regardless. + using var http = NewClient("alice"); + var response = await http.GetAsync( + $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetFile_unknown_id_returns_404() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + + using var http = NewClient("alice"); + var response = await http.GetAsync( + "/api/v1/fs/file/999999", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} \ No newline at end of file From a793996b7036d478c3bde4157fc3ae1ceeebef84 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 25 Sep 2026 10:20:18 +0100 Subject: [PATCH 3/3] Fixes the swagger ui access --- .gitignore | 5 ++++- .../Communicating/CommentsController.cs | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 93d7878e1..c1b184a87 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,8 @@ DataDir/ *.log # gitleaks scan output (local secret-scan report, not for the repo) -gitleaks-report.json +/gitleaks-report.json +/test-report.html +/test-report.xml + diff --git a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs index 02dda32a1..569b5a6d7 100644 --- a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs +++ b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using Yavsc.Models; @@ -19,7 +20,7 @@ namespace Yavsc.Controllers public CommentsController(ApplicationDbContext context) { - _context = context; + _context = context; } [HttpGet("{id:long}", Name = "GetComment")] @@ -82,8 +83,17 @@ namespace Yavsc.Controllers return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated }); } + // --- Actions MVC scaffoldées (vues CRUD) --------------------- + // Ces actions ne font pas partie de la surface d'API documentée + // par Swagger. Elles sont exclues de l'ApiExplorer : sinon, comme + // le contrôleur porte [Route("~/api/v1/blogcomments")], leurs + // [HttpPost] sans template propre atterrissent sur la même route + // "POST api/v1/blogcomments" que l'action Post ci-dessus, et + // Swashbuckle lève une SwaggerGeneratorException (conflicting + // method/path) au démarrage de /swagger. // GET: Comments [HttpGet] + [ApiExplorerSettings(IgnoreApi = true)] public async Task Index() { var applicationDbContext = _context.Comment.Include(c => c.Post); @@ -91,6 +101,7 @@ namespace Yavsc.Controllers } // GET: Comments/Details/5 + [ApiExplorerSettings(IgnoreApi = true)] public async Task Details(long? id) { if (id == null) @@ -109,6 +120,7 @@ namespace Yavsc.Controllers // GET: Comments/Create (MVC form endpoint) [HttpGet("form")] + [ApiExplorerSettings(IgnoreApi = true)] public IActionResult Create() { ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title"); @@ -118,6 +130,7 @@ namespace Yavsc.Controllers // POST: Comments/Create (MVC form endpoint) [HttpPost("form")] [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] public async Task Create(Comment comment) { comment.UserCreated = User.GetUserId(); @@ -126,7 +139,7 @@ namespace Yavsc.Controllers // remove the stale binding error so a valid authenticated POST // does not fall into the invalid branch. ModelState.Remove(nameof(Comment.AuthorId)); - + if (ModelState.IsValid) { _context.Comment.Add(comment); @@ -138,6 +151,7 @@ namespace Yavsc.Controllers } // GET: Comments/Edit/5 + [ApiExplorerSettings(IgnoreApi = true)] public async Task Edit(long? id) { if (id == null) @@ -157,6 +171,7 @@ namespace Yavsc.Controllers // POST: Comments/Edit/5 [HttpPost] [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] public async Task Edit(Comment comment) { if (ModelState.IsValid) @@ -171,6 +186,7 @@ namespace Yavsc.Controllers // GET: Comments/Delete/5 [ActionName("Delete")] + [ApiExplorerSettings(IgnoreApi = true)] public async Task Delete(long? id) { if (id == null) @@ -190,6 +206,7 @@ namespace Yavsc.Controllers // POST: Comments/Delete/5 [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] public async Task DeleteConfirmed(long id) { Comment comment = await _context.Comment.SingleAsync(m => m.Id == id);