From ad5e9090ee22d8d8682a04b9f3ea880612d4821e Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 6 Sep 2026 19:16:41 +0100 Subject: [PATCH 01/10] fix(api): harden billing/blog validation and update rc14 changelog --- CHANGELOG.md | 15 +++++ .../Fixtures/ApiWebServerFixture.cs | 12 ++++ .../FrontOfficeApiControllerTests.cs | 3 +- .../RdvQueryApiControllerTests.cs | 30 +++++++++ .../Business/ActivityApiController.cs | 36 +++++++++-- .../Business/FrontOfficeApiController.cs | 9 +-- .../Business/HairCutQueryApiController.cs | 18 +++--- .../HairMultiCutQueryApiController.cs | 18 +++--- .../Business/RdvQueryApiController.cs | 64 ++++++++++++------- .../Controllers/BlogApiController.cs | 16 +++++ .../Communicating/CommentsController.cs | 3 +- .../Models/ApplicationDbContext.cs | 1 + src/Yavsc.Server/Models/Workflow/Activity.cs | 2 +- 13 files changed, 171 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df33e446..0befe9eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.0.8-rc14] - unstable + +### Added + +### Changed + +### Fixed + +* [Yavsc.Api] Correction d'un 500 sur le refresh du catalogue d'activites lorsque `Activity.Description` est `NULL` en base (nullabilite explicite + projection null-safe + gardes sur codes vides). +* [Yavsc.Api] Correction des erreurs 400/500 sur les routes billing (`Rdv`, `Brush`, `MBrush`) en imposant `ClientId` depuis l'utilisateur authentifie et en ignorant les champs server-owned lors de la validation modele. +* [Yavsc.Api] Correction du `PUT /api/v1/billing/Rdv/{id}`: mise a jour controlee de l'entite existante (et non remplacement brut du graphe JSON), ce qui supprime les `BadRequest` parasites. +* [Yavsc.Api] Correction du flux FrontOffice accept/reject de query: sauvegarde avec contexte utilisateur et fallback d'injection pour `IBillingService` afin d'eviter les erreurs serveur en environnement de test. +* [Yavsc.Blogs] Correction des `BadRequest` sur `POST/PUT /api/v1/blogspot` avec payload JSON (PostIt): les proprietes de navigation/serveur (`Author`, `Tags`, `Comments`, audit) ne bloquent plus la validation. +* [Yavsc.Org] Correction du flux MVC de creation de commentaire: `SaveChangesAsync(userId)` est utilise pour renseigner les champs d'audit requis (`UserCreated`/`UserModified`). +* [Yavsc.Api.Test] Stabilisation des fixtures de seed billing: remplissage des metadonnees d'audit (`UserCreated`, `UserModified`, dates) pour eviter les echecs SQLite `NOT NULL`. ## [1.0.8-rc13] - unstable diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs index ca4ae70f9..e2a8b3376 100644 --- a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs +++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs @@ -204,6 +204,10 @@ public sealed class ApiWebServerFixture : WebHostFixture ClientId = "alice", PerformerId = "alice", Consent = true, + UserCreated = "alice", + UserModified = "alice", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, EventDate = DateTime.UtcNow.AddDays(1), Location = location, Reason = "Initial rendez-vous", @@ -293,6 +297,10 @@ public sealed class ApiWebServerFixture : WebHostFixture ClientId = "alice", PerformerId = "alice", Consent = true, + UserCreated = "alice", + UserModified = "alice", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, EventDate = DateTime.UtcNow.AddDays(3), Location = location, PrestationId = prestation1.Id, @@ -308,6 +316,10 @@ public sealed class ApiWebServerFixture : WebHostFixture ClientId = "alice", PerformerId = "alice", Consent = true, + UserCreated = "alice", + UserModified = "alice", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, EventDate = DateTime.UtcNow.AddDays(4), Location = location, Prestations = new List diff --git a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs index 676056d3f..97ed2e61c 100644 --- a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs +++ b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs @@ -49,8 +49,9 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); diff --git a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs index 8fe7e959e..fcd87f9a3 100644 --- a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs +++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs @@ -82,4 +82,34 @@ public sealed class RdvQueryApiControllerTests : IClassFixture(TestContext.Current.CancellationToken); + Assert.NotNull(created); + Assert.Equal("alice", created!.ClientId); + } } diff --git a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs index a8d288063..aef393868 100644 --- a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs @@ -51,6 +51,14 @@ namespace Yavsc.Controllers .Distinct() .ToArray(); + // Some providers are brittle when translating Contains over an + // empty in-memory array. If there is no candidate activity code, + // the catalog is empty by definition. + if (codes.Length == 0) + { + return Ok(new List()); + } + var performerCounts = await ( from ua in _context.UserActivities.AsNoTracking() where !string.IsNullOrWhiteSpace(ua.DoesCode) && codes.Contains(ua.DoesCode) @@ -64,10 +72,10 @@ namespace Yavsc.Controllers var filteredActivities = activities .Where(a => - (performerCounts.TryGetValue(a.Code, out var ownCount) && ownCount > 0) + (TryGetPerformerCount(performerCounts, a.Code, out var ownCount) && ownCount > 0) || (a.Children ?? new List()) .Where(c => !c.Hidden) - .Any(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0)) + .Any(c => TryGetPerformerCount(performerCounts, c.Code, out var childCount) && childCount > 0)) .ToList(); return Ok(filteredActivities.Select(a => ToBrowseItem(a, performerCounts)).ToList()); @@ -269,10 +277,10 @@ namespace Yavsc.Controllers Code = activity.Code, Name = activity.Name, ParentCode = activity.ParentCode, - Description = activity.Description, + Description = activity.Description ?? string.Empty, Photo = activity.Photo, Rate = activity.Rate, - PerformerCount = performerCounts.TryGetValue(activity.Code, out var count) ? count : 0, + PerformerCount = TryGetPerformerCount(performerCounts, activity.Code, out var count) ? count : 0, Forms = (activity.Forms ?? Enumerable.Empty()) .Select(f => new CommandFormSummary { @@ -283,17 +291,17 @@ namespace Yavsc.Controllers .ToList(), Children = (activity.Children ?? Enumerable.Empty()) .Where(c => !c.Hidden) - .Where(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0) + .Where(c => TryGetPerformerCount(performerCounts, c.Code, out var childCount) && childCount > 0) .OrderByDescending(c => c.Rate) .Select(c => new ActivityInfo { Code = c.Code, Name = c.Name, ParentCode = c.ParentCode, - Description = c.Description, + Description = c.Description ?? string.Empty, Photo = c.Photo, Rate = c.Rate, - PerformerCount = performerCounts.TryGetValue(c.Code, out var childCount) ? childCount : 0, + PerformerCount = TryGetPerformerCount(performerCounts, c.Code, out var childCount) ? childCount : 0, Forms = (c.Forms ?? Enumerable.Empty()) .Select(f => new CommandFormSummary { @@ -306,5 +314,19 @@ namespace Yavsc.Controllers .ToList(), }; } + + private static bool TryGetPerformerCount( + IReadOnlyDictionary performerCounts, + string code, + out int count) + { + if (string.IsNullOrWhiteSpace(code)) + { + count = 0; + return false; + } + + return performerCounts.TryGetValue(code, out count); + } } } diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index a4ecbf2f8..3adac5b03 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Services; +using Yavsc.Server.Helpers; using Yavsc.ViewModels.FrontOffice; namespace Yavsc.ApiControllers @@ -15,10 +16,10 @@ namespace Yavsc.ApiControllers private IBillingService billing; - public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing) + public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null) { dbContext = context; - this.billing = billing; + this.billing = billing ?? new BillingService(context); } [HttpGet("profiles/{actCode}")] @@ -36,7 +37,7 @@ namespace Yavsc.ApiControllers if (billing == null) return BadRequest(); billing.Status = QueryStatus.Rejected; - dbContext.SaveChanges(); + dbContext.SaveChanges(User.GetUserId()); return Ok(); } @@ -48,7 +49,7 @@ namespace Yavsc.ApiControllers var billing = BillingService.GetBillable(dbContext, billingCode, queryId); if (billing == null) return BadRequest(); billing.Status = QueryStatus.Accepted; - dbContext.SaveChanges(); + dbContext.SaveChanges(User.GetUserId()); return Ok(); } } diff --git a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs index 6c7c1ba68..7f445fdd9 100644 --- a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs @@ -82,19 +82,17 @@ public class HairCutQueryApiController : Controller public async Task PostQuery([FromBody] HairCutQuery query, CancellationToken cancellationToken) { var uid = User.GetUserId(); - if (string.IsNullOrWhiteSpace(query.ClientId)) - { - query.ClientId = uid; - } + query.ClientId = uid; + ModelState.Remove("Client"); ModelState.Remove("ClientId"); + ModelState.Remove("UserCreated"); + ModelState.Remove("UserModified"); + ModelState.Remove("SelectedProfile"); ModelState.Remove("Prestation"); - - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) - { - ModelState.AddModelError("ClientId", "You can only create your own HairCutQuery"); - return BadRequest(ModelState); - } + ModelState.Remove("PerformerProfile"); + ModelState.Remove("Context"); + ModelState.Remove("Regularization"); query.Prestation = await _context.HairPrestation .SingleOrDefaultAsync(p => p.Id == query.PrestationId, cancellationToken); diff --git a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs index f850b3f41..3b33c4160 100644 --- a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs @@ -89,18 +89,16 @@ public class HairMultiCutQueryApiController : Controller public async Task PostQuery([FromBody] HairMultiCutQuery query, CancellationToken cancellationToken) { var uid = User.GetUserId(); - if (string.IsNullOrWhiteSpace(query.ClientId)) - { - query.ClientId = uid; - } + query.ClientId = uid; + ModelState.Remove("Client"); ModelState.Remove("ClientId"); - - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) - { - ModelState.AddModelError("ClientId", "You can only create your own HairMultiCutQuery"); - return BadRequest(ModelState); - } + ModelState.Remove("UserCreated"); + ModelState.Remove("UserModified"); + ModelState.Remove("SelectedProfile"); + ModelState.Remove("PerformerProfile"); + ModelState.Remove("Context"); + ModelState.Remove("Regularization"); if (query.Prestations is null || query.Prestations.Count == 0) { diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs index fcfac2b22..dd2982138 100644 --- a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs @@ -65,18 +65,17 @@ public class RdvQueryApiController : Controller public async Task PostQuery([FromBody] RdvQuery query, CancellationToken cancellationToken) { var uid = User.GetUserId(); - if (string.IsNullOrWhiteSpace(query.ClientId)) - { - query.ClientId = uid; - } + // Security: the caller always posts for themselves. + query.ClientId = uid; + ModelState.Remove("Client"); ModelState.Remove("ClientId"); - - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) - { - ModelState.AddModelError("ClientId", "You can only create your own RdvQuery"); - return BadRequest(ModelState); - } + ModelState.Remove("UserCreated"); + ModelState.Remove("UserModified"); + ModelState.Remove("SelectedProfile"); + ModelState.Remove("PerformerProfile"); + ModelState.Remove("Context"); + ModelState.Remove("Regularization"); if (!ModelState.IsValid) { @@ -123,23 +122,44 @@ public class RdvQueryApiController : Controller [HttpPut("{id}")] public async Task PutQuery([FromRoute] long id, [FromBody] RdvQuery query, CancellationToken cancellationToken) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - if (id != query.Id) - { - return BadRequest(); - } - var uid = User.GetUserId(); - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + var existing = await _context.RdvQueries + .Include(q => q.Location) + .SingleOrDefaultAsync(q => q.Id == id, cancellationToken); + + if (existing is null) + { + return NotFound(); + } + + if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) { return Forbid(); } - _context.Entry(query).State = EntityState.Modified; + existing.ActivityCode = query.ActivityCode; + existing.PerformerId = query.PerformerId; + existing.Consent = query.Consent; + existing.EventDate = query.EventDate; + existing.LocationType = query.LocationType; + existing.Reason = query.Reason; + existing.Status = query.Status; + existing.Provisional = query.Provisional; + + if (query.Location is not null) + { + var resolvedLocation = await _context.Locations.FirstOrDefaultAsync( + x => x.Address == query.Location.Address + && x.Longitude == query.Location.Longitude + && x.Latitude == query.Location.Latitude, + cancellationToken); + + existing.Location = resolvedLocation ?? query.Location; + if (resolvedLocation is null) + { + _context.Attach(query.Location); + } + } try { diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index 8c76f7eea..b757d711b 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -55,6 +55,14 @@ namespace Yavsc.Blogs.Controllers [HttpPut("{id}")] public async Task PutBlog(long id, [FromBody] Models.Blog.BlogPost blog) { + // These properties are server-managed or optional graph members and + // should not block JSON payloads coming from API clients. + ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Tags)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Comments)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified)); + if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -87,6 +95,14 @@ namespace Yavsc.Blogs.Controllers [HttpPost] public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) { + // These properties are server-managed or optional graph members and + // should not block JSON payloads coming from API clients. + ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Tags)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Comments)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified)); + if (!ModelState.IsValid) { return BadRequest(ModelState); diff --git a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs index 3690671c5..02dda32a1 100644 --- a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs +++ b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs @@ -121,6 +121,7 @@ namespace Yavsc.Controllers public async Task Create(Comment comment) { comment.UserCreated = User.GetUserId(); + comment.UserModified = comment.UserCreated; // AuthorId/UserCreated is set server-side after model binding; // remove the stale binding error so a valid authenticated POST // does not fall into the invalid branch. @@ -129,7 +130,7 @@ namespace Yavsc.Controllers if (ModelState.IsValid) { _context.Comment.Add(comment); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync(comment.UserCreated); return RedirectToAction("Index"); } ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title", comment.ReceiverId); diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index cb08023a3..ade093c15 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -108,6 +108,7 @@ namespace Yavsc.Models ; builder.Entity().Property(a => a.ParentCode).IsRequired(false); + builder.Entity().Property(a => a.Description).IsRequired(false); builder.Entity().HasKey(c => c.Code); builder.Entity() diff --git a/src/Yavsc.Server/Models/Workflow/Activity.cs b/src/Yavsc.Server/Models/Workflow/Activity.cs index e3303f5ac..e82ac9709 100644 --- a/src/Yavsc.Server/Models/Workflow/Activity.cs +++ b/src/Yavsc.Server/Models/Workflow/Activity.cs @@ -37,7 +37,7 @@ namespace Yavsc.Models.Workflow public virtual List Children { get; set; } [Display(Name = "Description")] - public string Description { get; set; } + public string? Description { get; set; } [Display(Name = "Photo")] public string? Photo { get; set; } From 703757d326fdc5853c690719b530c2467b53e192 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 6 Sep 2026 19:47:38 +0100 Subject: [PATCH 02/10] handles duplicate email at register --- .../Accounting/AccountController.cs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs index 77e0e32a4..44927d461 100644 --- a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs +++ b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs @@ -467,8 +467,25 @@ IHtmlLocalizerFactory htmlLocalizerFactory, if (ModelState.IsValid) { + var existingUser = await _userManager.FindByEmailAsync(model.Email); + if (existingUser is not null) + { + ModelState.AddModelError(nameof(model.Email), _localizer["DuplicateEmail"]); + return View(model); + } + var user = new ApplicationUser { UserName = model.UserName, Email = model.Email }; - var result = await _userManager.CreateAsync(user, model.Password); + IdentityResult result; + try + { + result = await _userManager.CreateAsync(user, model.Password); + } + catch (DbUpdateException ex) when (IsDuplicateEmailViolation(ex)) + { + _logger.LogWarning(ex, "Registration rejected: duplicate email '{Email}'.", model.Email); + ModelState.AddModelError(nameof(model.Email), _localizer["DuplicateEmail"]); + return View(model); + } if (result.Succeeded) { _logger.LogInformation(3, "User created a new account with password."); @@ -517,6 +534,21 @@ IHtmlLocalizerFactory htmlLocalizerFactory, return View(model); } + private static bool IsDuplicateEmailViolation(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is PostgresException pg + && pg.SqlState == PostgresErrorCodes.UniqueViolation + && string.Equals(pg.ConstraintName, "AK_AspNetUsers_Email", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + [Authorize, HttpPost, ValidateAntiForgeryToken] public async Task SendConfirationEmail() { From ebe9d0b7401265d72ca4bd73d881748b475bd2a9 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 6 Sep 2026 19:48:51 +0100 Subject: [PATCH 03/10] correctif UTC sur le POST/PUT RDV --- .../RdvQueryApiControllerTests.cs | 31 +++++++++++++++++++ .../Business/RdvQueryApiController.cs | 13 +++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs index fcd87f9a3..668814239 100644 --- a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs +++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs @@ -112,4 +112,35 @@ public sealed class RdvQueryApiControllerTests : IClassFixture(TestContext.Current.CancellationToken); + Assert.NotNull(created); + Assert.Equal(DateTimeKind.Utc, created!.EventDate.Kind); + } } diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs index dd2982138..48e3be2e1 100644 --- a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs @@ -67,6 +67,7 @@ public class RdvQueryApiController : Controller var uid = User.GetUserId(); // Security: the caller always posts for themselves. query.ClientId = uid; + query.EventDate = EnsureUtc(query.EventDate); ModelState.Remove("Client"); ModelState.Remove("ClientId"); @@ -140,7 +141,7 @@ public class RdvQueryApiController : Controller existing.ActivityCode = query.ActivityCode; existing.PerformerId = query.PerformerId; existing.Consent = query.Consent; - existing.EventDate = query.EventDate; + existing.EventDate = EnsureUtc(query.EventDate); existing.LocationType = query.LocationType; existing.Reason = query.Reason; existing.Status = query.Status; @@ -206,4 +207,14 @@ public class RdvQueryApiController : Controller { return _context.RdvQueries.Any(e => e.Id == id); } + + private static DateTime EnsureUtc(DateTime value) + { + return value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + } } From 1ec7c7a75a9c277600924b927bf4ef1046011ef8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 6 Sep 2026 20:24:19 +0100 Subject: [PATCH 04/10] postit: add billing query details page and refresh rc14 changelog --- CHANGELOG.md | 10 + .../Helpers/ServiceCollectionHelpers.cs | 1 + src/PostIt/PostIt/ViewLocator.cs | 6 +- .../ViewModels/BillingQueriesPageViewModel.cs | 19 +- .../BillingQueryDetailsPageViewModel.cs | 208 ++++++++++++++++++ .../PostIt/Views/BillingQueriesPage.axaml | 83 +++++-- .../Views/BillingQueryDetailsPage.axaml | 131 +++++++++++ .../Views/BillingQueryDetailsPage.axaml.cs | 17 ++ 8 files changed, 444 insertions(+), 31 deletions(-) create mode 100644 src/PostIt/PostIt/ViewModels/BillingQueryDetailsPageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/BillingQueryDetailsPage.axaml create mode 100644 src/PostIt/PostIt/Views/BillingQueryDetailsPage.axaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0befe9eb3..111c1491a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,23 @@ ### Added +* [PostIt] Ajout d'un `BillingQueryDetailsPageViewModel` et de sa page associee pour afficher le detail d'une commande billing depuis l'historique. +* [PostIt] Ajout d'un mode detail avec section metier (statut, date, description, motif, infos) et section technique repliable (code, client, provision, lieu, prestations). +* [PostIt] Ajout d'un badge de statut enrichi (couleur + pictogramme) sur le detail d'une commande pour visualiser l'etat en un coup d'oeil. +* [PostIt] Ajout d'un bloc d'actions rapide en tete du detail (`Retour`, `Ouvrir en edition`) pour eviter le scroll jusqu'au bas de page. +* [PostIt] Ajout d'un style monospace sur les metadonnees techniques (code billing, client, provision, lieu, prestations) pour faciliter la lecture des identifiants et valeurs brutes. + ### Changed +* [PostIt] Le bouton d'ouverture depuis la liste billing ouvre maintenant une page de detail dediee avant l'eventuelle edition. +* [PostIt] Amelioration UX des pages billing: badges de statut colores, actions remontees en haut de page, et typographie monospace sur les metadonnees techniques. + ### Fixed * [Yavsc.Api] Correction d'un 500 sur le refresh du catalogue d'activites lorsque `Activity.Description` est `NULL` en base (nullabilite explicite + projection null-safe + gardes sur codes vides). * [Yavsc.Api] Correction des erreurs 400/500 sur les routes billing (`Rdv`, `Brush`, `MBrush`) en imposant `ClientId` depuis l'utilisateur authentifie et en ignorant les champs server-owned lors de la validation modele. * [Yavsc.Api] Correction du `PUT /api/v1/billing/Rdv/{id}`: mise a jour controlee de l'entite existante (et non remplacement brut du graphe JSON), ce qui supprime les `BadRequest` parasites. +* [Yavsc.Api] Correction PostgreSQL `timestamptz` sur RDV: normalisation UTC de `EventDate` sur `POST/PUT /api/v1/billing/Rdv` pour eviter l'erreur `Cannot write DateTime with Kind=Local`. * [Yavsc.Api] Correction du flux FrontOffice accept/reject de query: sauvegarde avec contexte utilisateur et fallback d'injection pour `IBillingService` afin d'eviter les erreurs serveur en environnement de test. * [Yavsc.Blogs] Correction des `BadRequest` sur `POST/PUT /api/v1/blogspot` avec payload JSON (PostIt): les proprietes de navigation/serveur (`Author`, `Tags`, `Comments`, audit) ne bloquent plus la validation. * [Yavsc.Org] Correction du flux MVC de creation de commentaire: `SaveChangesAsync(userId)` est utilise pour renseigner les champs d'audit requis (`UserCreated`/`UserModified`). diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 00a8bde04..c0831b290 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -57,6 +57,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); services.AddSingleton(api); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 0055a9e60..f866e1bac 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -19,7 +19,7 @@ namespace PostIt; public class ViewLocator : IDataTemplate { - public Control Build(object? data) + public Control Build(object? data) { try { @@ -49,10 +49,12 @@ public class ViewLocator : IDataTemplate AddCircleMemberDialogViewModel => services.GetRequiredService(), CirclesPageViewModel => services.GetRequiredService(), PostAclDialogViewModel => services.GetRequiredService(), + BillingQueriesPageViewModel => services.GetRequiredService(), + BillingQueryDetailsPageViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; } - public bool Match(object? data) => data is ViewModelBase; + public bool Match(object? data) => data is ViewModelBase; } diff --git a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs index d3c097f43..bccf97167 100644 --- a/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/BillingQueriesPageViewModel.cs @@ -43,7 +43,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV ? $"Demandes en cours ({Form.Title})" : $"Commandes {Form.Title}"; public string ContextLabel => $"{Performer.UserName} · {Activity.Name}"; - public bool CanOpenDetails => !IsReadOnly; + public bool CanOpenDetails => true; public override bool CanNavigateNext { @@ -75,7 +75,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV public Task InitializeAsync() => RefreshAsync(); - private bool CanOpenSelectedQuery() => !IsReadOnly && SelectedQuery is not null; + private bool CanOpenSelectedQuery() => SelectedQuery is not null; [RelayCommand] public async Task RefreshAsync() @@ -114,12 +114,6 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV [RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))] public async Task OpenSelectedQueryAsync() { - if (IsReadOnly) - { - this.SetWarningStatus("Mode lecture seule: l'ouverture en modification est désactivée."); - return; - } - if (SelectedQuery is null) { this.SetWarningStatus("Sélectionnez une commande."); @@ -136,8 +130,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV try { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); - var vm = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient); - await vm!.InitializeAsync(details).ConfigureAwait(true); + var vm = new BillingQueryDetailsPageViewModel( + Activity, + Performer, + Form, + _billingClient, + details, + IsReadOnly); 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/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingQueryDetailsPageViewModel.cs new file mode 100644 index 000000000..cd13daaf1 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/BillingQueryDetailsPageViewModel.cs @@ -0,0 +1,208 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly BillingApiClient _billingClient; + private readonly BillingQueryDetailsDto _details; + + public ActivityInfo Activity { get; } + public ActivityUserDisplayItem Performer { get; } + public CommandFormSummary Form { get; } + public bool IsReadOnly { get; } + + public long Id => _details.Id; + public string Title => $"Detail commande #{_details.Id}"; + public string ContextLabel => $"{Performer.UserName} · {Activity.Name} · {Form.Title}"; + public string StatusLabel => _details.Status.ToString(); + public string StatusGlyph => GetStatusGlyph(_details.Status); + public string StatusBadgeBackground => GetStatusBadgeBackground(_details.Status); + public string StatusBadgeBorder => GetStatusBadgeBorder(_details.Status); + public string StatusBadgeForeground => GetStatusBadgeForeground(_details.Status); + public string TitleForeground => StatusBadgeForeground; + public string BillingCode => _details.BillingCode; + public string Description => EmptyAsPlaceholder(_details.Description, "(sans description)"); + public string Reason => EmptyAsPlaceholder(_details.Reason, "(aucun motif)"); + public string AdditionalInfo => EmptyAsPlaceholder(_details.AdditionalInfo, "(aucune info complementaire)"); + public string ClientId => EmptyAsPlaceholder(_details.ClientId, "(non renseigne)"); + public string EventDateLabel => _details.EventDate?.ToLocalTime().ToString("f") ?? "Date non precisee"; + public string ConsentLabel => _details.Consent ? "Oui" : "Non"; + public string ProvisionalLabel => _details.Provisional.HasValue ? _details.Provisional.Value.ToString("0.00") : "(non renseigne)"; + public string LocationLabel => BuildLocationLabel(_details.Location); + public string PrestationsLabel => BuildPrestationsLabel(_details); + public bool CanEdit => !IsReadOnly; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Pret."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public BillingQueryDetailsPageViewModel( + ActivityInfo activity, + ActivityUserDisplayItem performer, + CommandFormSummary form, + BillingApiClient billingClient, + BillingQueryDetailsDto details, + bool isReadOnly) + { + 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)); + _details = details ?? throw new ArgumentNullException(nameof(details)); + IsReadOnly = isReadOnly; + + this.SetInfoStatus("Details de commande charges."); + } + + [RelayCommand] + private async Task OpenEditorAsync() + { + if (IsReadOnly) + { + this.SetWarningStatus("Mode lecture seule: edition desactivee."); + return; + } + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + IsBusy = true; + try + { + var vm = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient); + if (vm is null) + { + this.SetWarningStatus("Ce formulaire n'est pas encore pris en charge en edition."); + return; + } + + await vm.InitializeAsync(_details).ConfigureAwait(true); + await app.PushPageAsync(vm).ConfigureAwait(true); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur lors de l'ouverture en edition: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task BackAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + await app.GoBackAsync().ConfigureAwait(true); + } + + private static string EmptyAsPlaceholder(string? value, string placeholder) + => string.IsNullOrWhiteSpace(value) ? placeholder : value; + + private static string BuildLocationLabel(BillingLocationDto? location) + { + if (location is null) + { + return "(non renseignee)"; + } + + var text = EmptyAsPlaceholder(location.Address, "adresse vide"); + if (location.Latitude.HasValue && location.Longitude.HasValue) + { + text += $" ({location.Latitude.Value:0.####}, {location.Longitude.Value:0.####})"; + } + + return text; + } + + private static string BuildPrestationsLabel(BillingQueryDetailsDto details) + { + if (details.PrestationIds.Count > 0) + { + return string.Join(", ", details.PrestationIds.Select(static id => id.ToString())); + } + + return details.PrestationId.HasValue + ? details.PrestationId.Value.ToString() + : "(aucune)"; + } + + private static string GetStatusBadgeBackground(QueryStatus status) + => status switch + { + QueryStatus.Accepted => "#E6F7EC", + QueryStatus.InProgress => "#FFF4D6", + QueryStatus.Rejected => "#FDECEA", + QueryStatus.Failed => "#ECEFF1", + QueryStatus.Success => "#E8F8EF", + _ => "#EAF3FF", + }; + + private static string GetStatusBadgeBorder(QueryStatus status) + => status switch + { + QueryStatus.Accepted => "#2E7D32", + QueryStatus.InProgress => "#B26A00", + QueryStatus.Rejected => "#C62828", + QueryStatus.Failed => "#607D8B", + QueryStatus.Success => "#1E8E3E", + _ => "#2A5EA8", + }; + + private static string GetStatusBadgeForeground(QueryStatus status) + => status switch + { + QueryStatus.Accepted => "#1B5E20", + QueryStatus.InProgress => "#7A4A00", + QueryStatus.Rejected => "#8E0000", + QueryStatus.Failed => "#37474F", + QueryStatus.Success => "#145A2A", + _ => "#1A4178", + }; + + private static string GetStatusGlyph(QueryStatus status) + => status switch + { + QueryStatus.Accepted => "OK", + QueryStatus.InProgress => "~", + QueryStatus.Rejected => "!", + QueryStatus.Failed => "X", + QueryStatus.Success => "V", + _ => "i", + }; +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/BillingQueriesPage.axaml b/src/PostIt/PostIt/Views/BillingQueriesPage.axaml index 2de514760..52b255129 100644 --- a/src/PostIt/PostIt/Views/BillingQueriesPage.axaml +++ b/src/PostIt/PostIt/Views/BillingQueriesPage.axaml @@ -5,38 +5,83 @@ x:Class="PostIt.Views.BillingQueriesPage" x:DataType="vm:BillingQueriesPageViewModel" Header="Commandes billing"> - - - + + + - -