diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index b9e6f17b2..f3c928ee0 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -19,21 +19,21 @@ on: required: false type: boolean push: - branches: [ "main" ] + branches: [ "main", "release/*", "hotfix/*", "fix/*" ] pull_request: - branches: [ "main", "release/*" ] + branches: [ "main", "release/*", "hotfix/*", "fix/*" ] jobs: build: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 steps: - name: Clone yavsc run: | set -e cd /src - git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src cd _src if [ -n "${GITHUB_REF:-}" ]; then git fetch origin "$GITHUB_REF" @@ -49,12 +49,6 @@ jobs: run: | echo "🏗️ Building the solution..." cd /src/_src && dotnet build --verbosity normal - - name: Install SkiaSharp native dependencies - run: | - echo "📦 Installing libfontconfig/libfreetype for SkiaSharp (Avalonia.Headless)..." - apt-get update && apt-get install -y --no-install-recommends \ - libfontconfig1 libfreetype6 libexpat1 zlib1g libbz2-1.0 libpng16-16 libbrotli1 \ - && rm -rf /var/lib/apt/lists/* - name: Test run: | echo "🚀 Lancement des tests..." diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 911fc831d..7d0670a17 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -52,7 +52,7 @@ jobs: release: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 steps: - name: Clone du repo au tag demandé env: @@ -69,7 +69,7 @@ jobs: cd /src if [[ ! -d _src/.git ]]; then - git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src fi cd _src @@ -175,12 +175,6 @@ jobs: run: | cd /src/_src dotnet restore - - name: Test - run: | - cd /src/_src && dotnet test \ - --verbosity normal \ - --filter="Category!=Platform-Android" \ - --logger "xunit;LogFileName=test-results.xml" - name: Build de PostIt.Android ARM64 run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 111c1491a..3f8253c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## [1.0.8-rc15] - unstable + +### Added + +* [PostIt] Saisie et envoi de pieces jointes sur un billet de blog: le client monte les fichiers en `multipart/form-data` lors du `Save`, ajoute un lien Markdown par piece jointe a l'article, puis re-PUT le billet. Une collection `DraftAttachments` porte les fichiers en cours d'edition. +* [PostIt] Apercu Markdown dans l'editeur de blogs (integration `MarkdownViewer.Core`) avec bascule edition/preview sur `BlogsPage`. +* [PostIt] Gestion des devis: page de liste `EstimateListPage` (vues client et prestataire), page d'edition `EstimateEditionPage` avec lignes de devis (`EstimateLineItemViewModel`), et flux de validation d'un devis. +* [PostIt] Flux des requetes en cours cote prestataire (`ProviderOngoingRequestsPage`) avec tri persistant. +* [Yavsc.Api] Endpoints devis `GET /api/v1/estimate/asclient` et `GET /api/v1/estimate/asprovider` pour lister les devis en cours selon le role. +* [Yavsc.Api] Endpoint `GET /api/v1/billing/provider/ongoing` et `POST /api/v1/billing/prosign/{billingCode}/{id}` pour le flux prestataire. +* [Yavsc.Api.Client] `EstimateApiClient` + DTOs (`EstimateDto`, `EstimateLineDto`) pour le pipeline devis cote client lourd. +* [Yavsc.Api.Client] Reprise de l'upload de fichiers blog dans `BlogApiClient` (`CreateMultipartContent`, `BlogUploadFile`) — la meme forme filaire que PostIt utilise en production. +* [Yavsc.Abstract] `FileServerUrlHelpers` pour deriver les URLs publiques des fichiers utilisateur depuis l'autorite OIDC (alignement sur `UserFilesPath`). +* [Yavsc.Server] `BlogSpotService.AttachFiles` : ecrit les pieces jointes sous la racine des fichiers utilisateur (`{UserFilesDirName}/{user}/blogs/{postId}/{fileName}`). +* [Yavsc.Blogs] `BlogApiController` accepte `multipart/form-data` (champ `blog` + parts `file`) sur `POST` et `PUT /api/v1/blogspot`, en plus du payload JSON existant. +* [PostIt] Variable d'environnement `POSTIT_SETTINGS_JSON` pour surcharger les parametres au lancement (utile en CI/test). +* [Yavsc.Server] Migration EF `fileACL`: `CircleAuthorizationToFile` gagne `OwnerId` + `Access` et un graphe proprieetaire, base du controle d'acces fichier par cercle. +* [Yavsc.Server] Migration EF `genericEstimate`: `Estimate.Query` passe de `RdvQuery` a `NominativeServiceCommand?` pour supporter des devis non lies a un RDV. +* [Yavsc.Server] Migrations EF `NominativeServiceCommand` et `AddHairCutQueryLocationId` (localisation de la coupe). +* [CI] Integration du scan de secrets Gitleaks dans le pipeline Forgejo + `.gitleaksignore`. +* [Tests] Non-regressions: round-trip multipart blog (`BlogApiTests`, `CreateMultipartContentTests` forme + serveur), `BlogAttachmentLinkTests`, `FileServerUrlHelpersTests`, `EstimateApiControllerTests`, VMs `EstimateList`/`EstimateEdition`/`ProviderOngoingRequests`, `BillingControllerTests` (filtrage par utilisateur), `GetOpenIdConfiguration` contre l'hote de test. + +### Changed + +* [PostIt] Renommage `MainPage` -> `BlogsPage` (et `PushMainPageAsync` -> `PushBlogsPageAsync`) pour refletter le role de la page. +* [PostIt] Refacto des `SiteSettings` : reorganisation des parametres, nouvelles valeurs par defaut, et indications plus claires dans les `appsettings`. +* [Yavsc.Api] `BillingController` ne liste plus que les codes billing de l'utilisateur authentifie (filtre par `UserId`), au lieu de l'ensemble des requetes prestataire. +* [Yavsc.Api] Mutualisation d'un `YavscMessageSender` partage dans l'hote API (au lieu d'une instance par controller). +* [Yavsc.Org] La detection du doublon d'email a l'inscription utilise desormais le code erreur generic `DbException` 23505 (fournisseur-agnostique) au lieu de `PostgresException`, pour rester correct en test SQLite. +* [PostIt] `YavscApiClient` tolere les corps vides (`204 No Content` sur `PUT /blogspot/{id}`) au lieu de lever un `JsonException`. +* [CI] Le pipeline compile avant les tests, installe les dependances natives, et retire le `ItemGroup.Using Include=Xunit` superflu. + +### Fixed + +* [PostIt] Suppression d'un faux message d'erreur affiche apres un `PUT /blogspot/{id}` reussi (204 traite comme succes, plus comme une reponse vide invalide). +* [PostIt] Correction de la creation des liens de pieces jointes (`TryAppendAttachmentLinks`) : segment proprietaire et chemin `blogs/{id}/{fileName}` alignes sur ce que `AttachFiles` ecrit reellement. +* [Yavsc.Blogs] Correction de l'upload / de la persistance des pieces jointes (`FileSystemHelpers` + `BlogApiController` multipart). +* [Tests] Stabilisation des suites: seeds billing avec metadonnees d'audit, `GetOpenIdConfiguration_returns_ok` contre l'hote de test, retraits d'assertions abusives, tests SQLite verts. +* [PostIt] Nettoyage de fichiers temporaires accidentels et ignore des repertoires `tmp` dans le depot. + ## [1.0.8-rc14] - unstable ### Added diff --git a/Directory.Build.props b/Directory.Build.props index 83d21579d..bf1bf8bcb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,5 +2,16 @@ Yavsc NU1701, NU1901, NU1902, NU1507 + + false diff --git a/Directory.Packages.props b/Directory.Packages.props index f1be93f91..feea66ec9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,22 +4,19 @@ 8.1.0-pazofrc007 + + - - - - - @@ -37,13 +34,9 @@ - - - - @@ -53,4 +46,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 2f27340e9..e8804a8bf 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -1,35 +1,31 @@ - + - - true - 12.1.1 - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + 12.1.1 + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 3d8be385c..a6d04cb33 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,8 +11,8 @@ false 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index 4534a2940..ff2f3f2b8 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,8 +6,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 948e726c5..36ed1251e 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,8 +7,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 app.manifest diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 4f102be20..112c11752 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -62,4 +62,17 @@ internal sealed class RecordingYavscApiClient : YavscApiClient }); return Task.FromResult(default(T)!); } + + /// Multipart overload: BlogApiClient routes + /// Create/UpdatePostAsync with attachments through here. + /// Without this override the call would fall through to the + /// base and attempt a real HTTP + /// round-trip against the stub authority. The body is a + /// factory, so we record the delegate itself; tests that + /// care about the multipart shape invoke it. + public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + { + _recorder.Calls.Add((method, path, contentFactory)); + return Task.FromResult(default(T)!); + } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs new file mode 100644 index 000000000..946d3d28e --- /dev/null +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -0,0 +1,79 @@ +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.ViewModels; + +namespace PostIt.Tests; + +/// +/// Pins the markdown link that +/// appends to the article after a successful attachment upload. +/// +/// The link must be an absolute URL derived from the OIDC +/// authority via FileServerUrlHelpers.GetUserFilesUri +/// (Yavsc.Abstract): the /files file server is hosted by +/// Yavsc.Org — the IdP — not by the blogs API host, so a link +/// built from BlogsApiUrl (or left relative) 404s when the +/// reader opens the post. The regression this guards: a generated +/// link of the shape /files/{user}/blogs/{id}/{name} +/// (relative, authority-less) instead of +/// https://{authority}/files/{user}/blogs/{id}/{name}. +/// +public class BlogAttachmentLinkTests +{ + [Fact] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() + { + const string authority = "https://idp.example.test"; + + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var blog = new BlogApiClient(api, "https://blogs.example.test/api/v1/"); + var settings = new Settings + { + Authentication = new AuthenticationSettings + { + Authority = authority, + ClientId = "stub", + Scopes = new[] { "openid" }, + }, + }; + var vm = new BlogsViewModel(blog, settings); + + // Update branch: an existing post is selected, the user + // edits the buffers and attaches a file. + vm.SelectedPost = new BlogPostDto + { + Id = 42, + AuthorId = "tester", + Title = "Avant", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + }; + vm.DraftTitle = "Après"; + vm.DraftArticle = "Contenu modifié."; + vm.DraftAttachments.Add(new BlogUploadFile( + "note.txt", + System.Text.Encoding.UTF8.GetBytes("payload test"), + "text/plain")); + + await vm.SaveAsync(); + + // The update branch issues two PUTs: the multipart one + // carrying the file, then — because TryAppendAttachmentLinks + // appended a link — a JSON one carrying the final article. + var linkPut = recorder.Calls.LastOrDefault( + c => c.method == HttpMethod.Put); + Assert.NotEqual(default, linkPut); + var sent = Assert.IsType>(linkPut.body); + var multipartContent = Assert.IsType(sent()); + + var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; + var sentStringContent = multipartContent.First() as StringContent ; + + var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + // The regression shape: a relative, authority-less link. + Assert.DoesNotContain("](/files/", sentString); + } +} diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs new file mode 100644 index 000000000..6158b4132 --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -0,0 +1,134 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class EstimateListPageViewModelTests +{ + [Fact] + public async Task Client_perspective_calls_asclient_endpoint() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); + Assert.Equal("Mes devis à valider", vm.Title); + Assert.Equal(2, vm.Estimates.Count); + // Most recent first + Assert.Equal(2, vm.Estimates[0].Id); + Assert.Equal(1, vm.Estimates[1].Id); + } + + [Fact] + public async Task Provider_perspective_calls_asprovider_endpoint() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); + Assert.Equal("Mes devis en attente", vm.Title); + Assert.Equal(2, vm.Estimates.Count); + } + + [Fact] + public async Task FilterText_filters_by_title_and_counterpart() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + + await vm.InitializeAsync(); + + vm.FilterText = "plomberie"; + Assert.Single(vm.Estimates); + Assert.Equal(1, vm.Estimates[0].Id); + + vm.FilterText = "BOB"; + Assert.Single(vm.Estimates); + Assert.Equal("bob", vm.Estimates[0].ClientId); + + vm.FilterText = string.Empty; + Assert.Equal(2, vm.Estimates.Count); + } + + [Fact] + public async Task Empty_result_sets_an_info_status() + { + var api = new StubEstimateListApi { Empty = true }; + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + + await vm.InitializeAsync(); + + Assert.Empty(vm.Estimates); + Assert.Contains("Aucun devis", vm.StatusMessage); + } + + private sealed class StubEstimateListApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + public bool Empty { get; init; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var items = Empty + ? new List() + : new List + { + new() + { + Id = 1, + Title = "Devis plomberie", + Description = "Réparation fuite", + ClientId = "bob", + OwnerId = "alice", + CommandType = "Rdv", + Bill = new List + { + new() { Name = "Main d'œuvre", Description = "Intervention", Count = 1, UnitaryCost = 80m }, + }, + }, + new() + { + Id = 2, + Title = "Devis électricité", + Description = "Mise aux normes", + ClientId = "carol", + OwnerId = "alice", + CommandType = "Rdv", + Bill = new List(), + }, + }; + return Task.FromResult((T)(object)items); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index 2c4f954d9..bbe2096e7 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,8 +8,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 6bd54ca20..bc5e7298f 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -62,6 +62,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 09632bdf8..a6f01cc31 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -5,8 +5,8 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -33,6 +33,7 @@ + diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 7e3187d8b..04dbce1fd 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -194,10 +194,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var dto = await JsonSerializer.DeserializeAsync(stream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false); - return dto!; + return await ReadJsonAsync(response, ct).ConfigureAwait(false); } /// @@ -215,10 +212,31 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var dto = await JsonSerializer.DeserializeAsync(stream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false); - return dto!; + return await ReadJsonAsync(response, ct).ConfigureAwait(false); + } + + /// + /// Deserialise the response body as , + /// tolerating an empty body. PUT /blogspot/{id} answers + /// 204 No Content: without this guard, + /// JsonSerializer.DeserializeAsync throws + /// JsonException: The input does not contain any JSON + /// tokens and the VM surfaces a successful save as an + /// error. Callers that genuinely need a payload + /// (GetPostAsync, CreatePostAsync) are unaffected: + /// their endpoints always return a body. + /// + private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken ct) + { + if (response.StatusCode == HttpStatusCode.NoContent) + return default!; + + var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(payload)) + return default!; + + return JsonSerializer.Deserialize(payload, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; } /// diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 6f8c5d902..11797b714 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -54,6 +54,7 @@ public class ViewLocator : IDataTemplate BillingQueryDetailsPageViewModel => services.GetRequiredService(), ProviderOngoingRequestsPageViewModel => services.GetRequiredService(), EstimateEditionPageViewModel => services.GetRequiredService(), + EstimateListPageViewModel => 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/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs new file mode 100644 index 000000000..e5c4d355f --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +/// +/// Point de vue d'une liste de devis en cours (non encore validés par +/// le client). +/// +public enum EstimateListPerspective +{ + /// « Mes devis à valider » — l'utilisateur courant est le client. + Client, + + /// « Mes devis en attente » — l'utilisateur courant est le fournisseur. + Provider, +} + +/// +/// Liste des devis en cours servie par GET api/v1/estimate/asclient +/// ou GET api/v1/estimate/asprovider selon la perspective choisie. +/// +public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly EstimateApiClient _estimateClient; + private readonly EstimateListPerspective _perspective; + private List _allEstimates = new(); + + public EstimateListPerspective Perspective => _perspective; + + public string Title => _perspective == EstimateListPerspective.Client + ? "Mes devis à valider" + : "Mes devis en attente"; + + public string Subtitle => _perspective == EstimateListPerspective.Client + ? "Point de vue client" + : "Point de vue fournisseur"; + + /// + /// Libellé de la colonne « autre partie » : côté client on montre + /// le fournisseur (OwnerId), côté fournisseur on montre le client. + /// + public string CounterpartLabel => _perspective == EstimateListPerspective.Client + ? "Fournisseur" + : "Client"; + + [ObservableProperty] + public partial ObservableCollection Estimates { get; set; } = new(); + + [ObservableProperty] + public partial string FilterText { get; set; } = string.Empty; + + [ObservableProperty] + public partial EstimateDto? SelectedEstimate { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Chargement des devis..."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des devis..."); + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective) + { + _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); + _perspective = perspective; + } + + public Task InitializeAsync() => RefreshAsync(); + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var items = (_perspective == EstimateListPerspective.Client + ? await _estimateClient.GetOngoingEstimatesAsClientAsync().ConfigureAwait(true) + : await _estimateClient.GetOngoingEstimatesAsProviderAsync().ConfigureAwait(true)) + ?? new List(); + + _allEstimates = items + .OrderByDescending(x => x.Id) + .ToList(); + + ApplyFilter(); + this.SetInfoStatus(_allEstimates.Count == 0 + ? "Aucun devis en cours." + : $"{_allEstimates.Count} devis en cours chargé(s)."); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + _allEstimates = new List(); + Estimates = new ObservableCollection(); + this.SetWarningStatus("Accès refusé (scope 'api'). Déconnectez puis reconnectez-vous."); + } + catch (Exception ex) + { + _allEstimates = new List(); + Estimates = new ObservableCollection(); + this.SetErrorStatus($"Erreur: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + partial void OnFilterTextChanged(string value) + { + ApplyFilter(); + } + + private void ApplyFilter() + { + var query = FilterText?.Trim(); + var filtered = string.IsNullOrWhiteSpace(query) + ? _allEstimates + : _allEstimates.Where(x => + ContainsInsensitive(x.Title, query) + || ContainsInsensitive(x.Description, query) + || ContainsInsensitive(x.CommandType, query) + || ContainsInsensitive(x.ClientId, query) + || ContainsInsensitive(x.OwnerId, query)) + .ToList(); + + Estimates = new ObservableCollection(filtered); + } + + private static bool ContainsInsensitive(string? value, string query) + => !string.IsNullOrWhiteSpace(value) + && value.Contains(query, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index 78be2e789..726129f12 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -80,6 +80,9 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel [ObservableProperty] public partial Settings Settings { get; private set; } + [ObservableProperty] + public partial bool IsMarkdownViewerVisible { get; set; } + [RelayCommand] internal async Task RefreshAsync() { @@ -116,7 +119,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // Build a fresh BlogPostDto from the editor buffer on // every Save — we no longer mutate SelectedPost in - // place. The previous behaviour copied the buffer + // place. The previous behavior copied the buffer // (which was a no-op when SelectedPost was null) // back onto the model and relied on a // [Required] violation to surface the missing @@ -160,6 +163,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } else { + TryAppendAttachmentLinks(SelectedPost, attachments); var update = new BlogPostDto { Id = SelectedPost.Id, @@ -170,24 +174,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateCreated = SelectedPost.DateCreated, DateModified = DateTime.UtcNow, }; - await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); - - if (TryAppendAttachmentLinks(SelectedPost, attachments)) - { - var linkUpdate = new BlogPostDto - { - Id = SelectedPost.Id, - AuthorId = SelectedPost.AuthorId, - Photo = SelectedPost.Photo, - Title = DraftTitle, - Article = DraftArticle ?? string.Empty, - DateCreated = SelectedPost.DateCreated, - DateModified = DateTime.UtcNow, - }; - await BlogClient.UpdatePostAsync(SelectedPost.Id, linkUpdate); - } - this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); DraftAttachments.Clear(); } @@ -374,6 +361,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel public BlogsViewModel() { + WindowTitle = "Blog"; SettingsModel = new Settings(); Init(SettingsModel); BlogClient = null; @@ -389,6 +377,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel this.SetInfoStatus("Prêt."); Settings = settings ?? new Settings(); SearchText = Settings.SearchText; + IsMarkdownViewerVisible = false; WindowTitle = "PostIt"; DraftTitle = string.Empty; DraftArticle = string.Empty; @@ -435,6 +424,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel /// public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { + WindowTitle = "Blog"; SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; Services = services; diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index fd2ff1091..ea92f1d0a 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -32,11 +32,15 @@ public class HomePageViewModel : ViewModelBase OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); + OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); + OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); } public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenProviderRequests { get; } + public IAsyncRelayCommand OpenClientEstimates { get; } + public IAsyncRelayCommand OpenProviderEstimates { get; } private async Task OpenActivitiesAsync() { @@ -72,6 +76,25 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + private async Task OpenEstimateListAsync(EstimateListPerspective perspective) + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var estimateClient = app.ServiceProvider?.GetRequiredService(); + if (estimateClient is null) + { + throw new InvalidOperationException("Client devis indisponible."); + } + + var vm = new EstimateListPageViewModel(estimateClient, perspective); + 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/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 35d5a59fe..1c2802e33 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -33,6 +33,9 @@ public partial class Settings : ViewModelBase [ObservableProperty] public partial string SearchText { get; set; } = string.Empty; + [ObservableProperty] + public partial bool IsMarkdownViewerVisible { get; set; } = false; + [ObservableProperty] public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty; diff --git a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml new file mode 100644 index 000000000..d5055dd1f --- /dev/null +++ b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml @@ -0,0 +1,71 @@ + + + + + + + + +