postit: persist settings save and apply ApiUrl changes without restart #51
13 changed files with 168 additions and 53 deletions
commit
d65624bfb8
15
CHANGELOG.md
15
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HairPrestationCollectionItem>
|
||||
|
|
|
|||
|
|
@ -49,8 +49,9 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
|
||||
using var http = NewClient();
|
||||
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var assertScope = _fixture.Services.CreateScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
|
|
|||
|
|
@ -82,4 +82,34 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
|
|||
var missingResponse = await http.GetAsync($"/api/v1/billing/Rdv/{fetched.Id}", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuery_ignores_client_field_and_uses_authenticated_user()
|
||||
{
|
||||
_fixture.ResetAndSeedRdvQueryGraph();
|
||||
using var http = NewClient(subject: "alice");
|
||||
|
||||
var createPayload = new
|
||||
{
|
||||
ActivityCode = "dev",
|
||||
PerformerId = "alice",
|
||||
Consent = true,
|
||||
EventDate = DateTime.UtcNow.AddDays(2),
|
||||
Location = new
|
||||
{
|
||||
Address = "2 rue du Test",
|
||||
Latitude = 48.8567,
|
||||
Longitude = 2.3523,
|
||||
},
|
||||
Reason = "Rendez-vous sans champ client",
|
||||
Status = QueryStatus.Inserted,
|
||||
};
|
||||
|
||||
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("alice", created!.ClientId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ActivityInfo>());
|
||||
}
|
||||
|
||||
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<Activity>())
|
||||
.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<CommandForm>())
|
||||
.Select(f => new CommandFormSummary
|
||||
{
|
||||
|
|
@ -283,17 +291,17 @@ namespace Yavsc.Controllers
|
|||
.ToList(),
|
||||
Children = (activity.Children ?? Enumerable.Empty<Activity>())
|
||||
.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<CommandForm>())
|
||||
.Select(f => new CommandFormSummary
|
||||
{
|
||||
|
|
@ -306,5 +314,19 @@ namespace Yavsc.Controllers
|
|||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPerformerCount(
|
||||
IReadOnlyDictionary<string, int> performerCounts,
|
||||
string code,
|
||||
out int count)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
count = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
return performerCounts.TryGetValue(code, out count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,19 +82,17 @@ public class HairCutQueryApiController : Controller
|
|||
public async Task<IActionResult> 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);
|
||||
|
|
|
|||
|
|
@ -89,18 +89,16 @@ public class HairMultiCutQueryApiController : Controller
|
|||
public async Task<IActionResult> 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -65,18 +65,17 @@ public class RdvQueryApiController : Controller
|
|||
public async Task<IActionResult> 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<IActionResult> 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ namespace Yavsc.Blogs.Controllers
|
|||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> 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);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ namespace Yavsc.Controllers
|
|||
public async Task<IActionResult> 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);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ namespace Yavsc.Models
|
|||
;
|
||||
|
||||
builder.Entity<Activity>().Property(a => a.ParentCode).IsRequired(false);
|
||||
builder.Entity<Activity>().Property(a => a.Description).IsRequired(false);
|
||||
|
||||
builder.Entity<Country>().HasKey(c => c.Code);
|
||||
builder.Entity<PerformerCodeInputValidation>()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ namespace Yavsc.Models.Workflow
|
|||
public virtual List<Activity> Children { get; set; }
|
||||
|
||||
[Display(Name = "Description")]
|
||||
public string Description { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Display(Name = "Photo")]
|
||||
public string? Photo { get; set; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue