diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 5246e6c81..4e7bea2e4 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -48,8 +48,8 @@ jobs: - name: Test run: | echo "🚀 Lancement des tests..." + export YAVSC_API_TEST_DB_PROVIDER=npgsql cd /src/_src && dotnet test \ --verbosity normal \ --filter="Category!=Platform-Android" \ - --logger "xunit;LogFileName=test-results.xml" \ - && echo "✅ Success !" || { echo "❌ Fail ($?)!"; exit 1; } + --logger "xunit;LogFileName=test-results.xml" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 82721d8a2..f5bcc21a2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -66,6 +66,28 @@ "isDefault": false } }, + { + "label": "test api backend (npgsql)", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": [ + "test", + "Yavsc.Api.Test.csproj", + "-v", + "minimal" + ], + "options": { + "cwd": "src/Yavsc.Api.Test", + "env": { + "YAVSC_API_TEST_DB_PROVIDER": "npgsql", + } + }, + "group": { + "kind": "test", + "isDefault": false + } + }, { "label": "build-webapi", "type": "process", diff --git a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs index 7a7d17df6..4f9d457a5 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs @@ -12,6 +12,9 @@ namespace PostIt.ViewModels.Commands; public partial class RdvViewModel : BillingCommandPageViewModel { + private long? _existingLocationId; + private bool _hydratingExistingQuery; + public override string SupportMessage => "ComplĂ©tez les informations du rendez-vous puis postez la commande."; [ObservableProperty] @@ -56,6 +59,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) { + _hydratingExistingQuery = true; ExistingQueryId = existingQuery.Id; CommandStatus = existingQuery.Status; Consent = existingQuery.Consent; @@ -70,11 +74,18 @@ public partial class RdvViewModel : BillingCommandPageViewModel if (existingQuery.Location is not null) { + _existingLocationId = existingQuery.Location.Id; Address = existingQuery.Location.Address ?? string.Empty; SuggestedAddress = string.Empty; Latitude = existingQuery.Location.Latitude; Longitude = existingQuery.Location.Longitude; } + else + { + _existingLocationId = null; + } + + _hydratingExistingQuery = false; this.SetInfoStatus($"Commande #{existingQuery.Id} chargĂ©e."); } @@ -117,20 +128,22 @@ public partial class RdvViewModel : BillingCommandPageViewModel } } - protected static object BuildLocationPayload(string address, double? latitude, double? longitude) + protected static BillingLocationDto BuildLocationPayload(string address, double? latitude, double? longitude, long? locationId = null) { if (latitude.HasValue && longitude.HasValue) { - return new + return new BillingLocationDto { + Id = locationId, Address = address, Latitude = latitude.Value, Longitude = longitude.Value, }; } - return new + return new BillingLocationDto { + Id = locationId, Address = address, }; } @@ -223,6 +236,30 @@ public partial class RdvViewModel : BillingCommandPageViewModel OnPropertyChanged(nameof(EventDateSelection)); } + partial void OnAddressChanged(string value) + { + if (_hydratingExistingQuery) + return; + + _existingLocationId = null; + } + + partial void OnLatitudeChanged(double? value) + { + if (_hydratingExistingQuery) + return; + + _existingLocationId = null; + } + + partial void OnLongitudeChanged(double? value) + { + if (_hydratingExistingQuery) + return; + + _existingLocationId = null; + } + protected override async Task SubmitAsync() { @@ -257,7 +294,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel try { var address = Address.Trim(); - var locationPayload = BuildLocationPayload(address, Latitude, Longitude); + var locationPayload = BuildLocationPayload(address, Latitude, Longitude, IsEditingExisting ? _existingLocationId : null); var payload = new BillingQueryDetailsDto { @@ -270,12 +307,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel Status = CommandStatus, Reason = Reason.Trim(), AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), - Location = new BillingLocationDto - { - Address = address, - Latitude = Latitude, - Longitude = Longitude, - } + Location = locationPayload }; if (IsEditingExisting) diff --git a/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs b/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs index 972c64350..c87af390c 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs @@ -23,8 +23,8 @@ public sealed class StatusNotice (Glyph, Background, BorderBrush, Foreground) = severity switch { - StatusSeverity.Error => ("!", "#FDECEA", "#C62828", "#7F1D1D"), - StatusSeverity.Warning => ("~", "#FFF8E1", "#E6A700", "#7C4A03"), + StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"), + StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"), _ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"), }; } diff --git a/src/Yavsc.Api.Client/BillingApiClient.cs b/src/Yavsc.Api.Client/BillingApiClient.cs index 3907dda25..a9e9deda6 100644 --- a/src/Yavsc.Api.Client/BillingApiClient.cs +++ b/src/Yavsc.Api.Client/BillingApiClient.cs @@ -164,6 +164,7 @@ public sealed class BillingApiClient ? null : new BillingLocationDto { + Id = dto.Location.Id > 0 ? dto.Location.Id : null, Address = dto.Location.Address ?? string.Empty, Latitude = dto.Location.Latitude, Longitude = dto.Location.Longitude, @@ -191,6 +192,7 @@ public sealed class BillingApiClient ? null : new BillingLocationDto { + Id = dto.Location.Id > 0 ? dto.Location.Id : null, Address = dto.Location.Address ?? string.Empty, Latitude = dto.Location.Latitude, Longitude = dto.Location.Longitude, @@ -220,6 +222,7 @@ public sealed class BillingApiClient ? null : new BillingLocationDto { + Id = dto.Location.Id > 0 ? dto.Location.Id : null, Address = dto.Location.Address ?? string.Empty, Latitude = dto.Location.Latitude, Longitude = dto.Location.Longitude, @@ -313,6 +316,11 @@ public sealed class BillingApiClient ["Address"] = location.Address, }; + if (location.Id.HasValue && location.Id.Value > 0) + { + payload["Id"] = location.Id.Value; + } + if (location.Latitude.HasValue) { payload["Latitude"] = location.Latitude.Value; @@ -328,6 +336,7 @@ public sealed class BillingApiClient private sealed class BillingLocationResponse { + public long Id { get; set; } public string? Address { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs index 6c0f538ea..3de5b37ec 100644 --- a/src/Yavsc.Api.Client/BlogApiClient.cs +++ b/src/Yavsc.Api.Client/BlogApiClient.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Yavsc.Blogspot; namespace Yavsc.Api.Client; diff --git a/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs index 6d658ce49..eb33ba816 100644 --- a/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs +++ b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; using Yavsc; namespace Yavsc.Api.Client; @@ -29,7 +30,14 @@ public sealed class BillingQueryDetailsDto public sealed class BillingLocationDto { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Id { get; set; } + public string Address { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? Latitude { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? Longitude { get; set; } } diff --git a/src/Yavsc.Api.Test/BillingControllerTests.cs b/src/Yavsc.Api.Test/BillingControllerTests.cs index a6445c776..d06ae7dba 100644 --- a/src/Yavsc.Api.Test/BillingControllerTests.cs +++ b/src/Yavsc.Api.Test/BillingControllerTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Yavsc.Api.Test.Fixtures; using Yavsc.Helpers; @@ -101,6 +102,58 @@ public sealed class BillingControllerTests : IClassFixture Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv); } + [Fact] + public async Task GetProviderOngoingCommands_ignores_rows_with_invalid_discriminator() + { + WorkflowHelpers.ConfigureBillingService(); + _fixture.ResetAndSeedActivityGraph(); + + using (var scope = _fixture.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + db.Database.ExecuteSqlInterpolated($@" +INSERT INTO ""NominativeServiceCommand"" +(""ActivityCode"", ""ClientId"", ""Consent"", ""DateCreated"", ""DateModified"", ""Description"", ""Discriminator"", ""PerformerId"", ""Status"", ""UserCreated"", ""UserModified"") +VALUES +({"dev"}, {"bob"}, {true}, {DateTime.UtcNow.AddMinutes(-5)}, {DateTime.UtcNow.AddMinutes(-4)}, {"Legacy malformed row"}, {""}, {"alice"}, {(int)QueryStatus.Accepted}, {"alice"}, {"alice"}); +"); + + var location = db.Locations.Single(); + db.RdvQueries.Add(new RdvQuery + { + ActivityCode = "dev", + ClientId = "bob", + PerformerId = "alice", + Consent = true, + UserCreated = "alice", + UserModified = "alice", + DateCreated = DateTime.UtcNow.AddMinutes(-3), + DateModified = DateTime.UtcNow.AddMinutes(-2), + EventDate = DateTime.UtcNow.AddDays(1), + Location = location, + Reason = "Commande valide", + Status = QueryStatus.InProgress, + Description = "Commande fournisseur valide", + }); + + db.SaveChanges(); + } + + using var http = NewClient(); + + var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.NotEmpty(payload!); + Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv && item.PerformerId == "alice"); + Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode)); + } + private sealed class ProviderOngoingCommandDto { public long Id { get; set; } diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs index 96c220bff..3079e48c5 100644 --- a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs +++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs @@ -3,6 +3,8 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using Npgsql; +using System.Runtime.Loader; using Yavsc.Controllers; using Yavsc.Interfaces.Workflow; using Yavsc.Models; @@ -18,27 +20,45 @@ namespace Yavsc.Api.Test.Fixtures; public sealed class ApiWebServerFixture : WebHostFixture { + private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; + private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; + private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true"; + protected override int HttpsPort => 5104; private static SqliteConnection? _sharedSqliteConnection; private static readonly object _sqliteLock = new(); + private static readonly object _npgsqlLock = new(); + private static string? _sharedNpgsqlConnectionString; + private static string? _sharedNpgsqlAdminConnectionString; + private static string? _sharedNpgsqlDatabaseName; + private static bool _npgsqlCleanupRegistered; protected override WebApplication BuildApp(WebApplicationBuilder builder) { - SqliteConnection sharedConnection; - lock (_sqliteLock) + if (UseNpgsqlProvider()) { - if (_sharedSqliteConnection is null) - { - _sharedSqliteConnection = new SqliteConnection( - "Data Source=YavscApiTests;Mode=Memory;Cache=Shared"); - _sharedSqliteConnection.Open(); - } - sharedConnection = _sharedSqliteConnection; + var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated(); + builder.Services.AddDbContext(opt => + opt.UseNpgsql(npgsqlConnectionString)); } + else + { + SqliteConnection sharedConnection; + lock (_sqliteLock) + { + if (_sharedSqliteConnection is null) + { + _sharedSqliteConnection = new SqliteConnection( + "Data Source=YavscApiTests;Mode=Memory;Cache=Shared"); + _sharedSqliteConnection.Open(); + } + sharedConnection = _sharedSqliteConnection; + } - builder.Services.AddDbContext(opt => - opt.UseSqlite(sharedConnection)); + builder.Services.AddDbContext(opt => + opt.UseSqlite(sharedConnection)); + } builder.Services.AddControllers() .AddApplicationPart(typeof(ActivityApiController).Assembly); @@ -90,6 +110,127 @@ public sealed class ApiWebServerFixture : WebHostFixture public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal)); + public static bool UseNpgsqlProvider() + => string.Equals( + Environment.GetEnvironmentVariable(DbProviderEnvVar), + "npgsql", + StringComparison.OrdinalIgnoreCase); + + private static string EnsureNpgsqlDatabaseCreated() + { + lock (_npgsqlLock) + { + if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString)) + { + return _sharedNpgsqlConnectionString; + } + + var adminConnectionString = BuildAdminConnectionString(); + var databaseName = $"yavsc_api_test_{Guid.NewGuid():N}"; + + using (var adminConnection = new NpgsqlConnection(adminConnectionString)) + { + adminConnection.Open(); + using var createCommand = adminConnection.CreateCommand(); + createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\""; + createCommand.ExecuteNonQuery(); + } + + var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString) + { + Database = databaseName, + Pooling = false, + IncludeErrorDetail = true + }; + + _sharedNpgsqlAdminConnectionString = adminConnectionString; + _sharedNpgsqlDatabaseName = databaseName; + _sharedNpgsqlConnectionString = testConnectionBuilder.ToString(); + RegisterNpgsqlCleanup(); + return _sharedNpgsqlConnectionString; + } + } + + private static string BuildAdminConnectionString() + { + var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar); + var source = string.IsNullOrWhiteSpace(configured) + ? DefaultDevelopmentConnectionString + : configured; + + var builder = new NpgsqlConnectionStringBuilder(source) + { + Pooling = false, + IncludeErrorDetail = true + }; + + if (string.IsNullOrWhiteSpace(configured)) + { + builder.Database = "postgres"; + } + else if (string.IsNullOrWhiteSpace(builder.Database)) + { + builder.Database = "postgres"; + } + + return builder.ToString(); + } + + private static void RegisterNpgsqlCleanup() + { + if (_npgsqlCleanupRegistered) + { + return; + } + + AppDomain.CurrentDomain.ProcessExit += (_, __) => DropTemporaryNpgsqlDatabase(); + AssemblyLoadContext.Default.Unloading += _ => DropTemporaryNpgsqlDatabase(); + _npgsqlCleanupRegistered = true; + } + + private static void DropTemporaryNpgsqlDatabase() + { + lock (_npgsqlLock) + { + if (string.IsNullOrWhiteSpace(_sharedNpgsqlDatabaseName) + || string.IsNullOrWhiteSpace(_sharedNpgsqlAdminConnectionString)) + { + return; + } + + try + { + using var adminConnection = new NpgsqlConnection(_sharedNpgsqlAdminConnectionString); + adminConnection.Open(); + + using (var terminateCommand = adminConnection.CreateCommand()) + { + terminateCommand.CommandText = @" +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname = @databaseName + AND pid <> pg_backend_pid();"; + terminateCommand.Parameters.AddWithValue("databaseName", _sharedNpgsqlDatabaseName); + terminateCommand.ExecuteNonQuery(); + } + + using var dropCommand = adminConnection.CreateCommand(); + dropCommand.CommandText = $"DROP DATABASE IF EXISTS \"{_sharedNpgsqlDatabaseName}\""; + dropCommand.ExecuteNonQuery(); + } + catch + { + // Best-effort cleanup only. + } + finally + { + _sharedNpgsqlConnectionString = null; + _sharedNpgsqlAdminConnectionString = null; + _sharedNpgsqlDatabaseName = null; + } + } + } + private sealed class NoopMessageSender : IYavscMessageSender { public Task NotifyBookQueryAsync(IEnumerable connectionIds, RdvQueryEvent ev) diff --git a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs index 668814239..c3e4aad2b 100644 --- a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs +++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs @@ -143,4 +143,87 @@ public sealed class RdvQueryApiControllerTests : IClassFixture(TestContext.Current.CancellationToken); + Assert.NotNull(created); + Assert.NotNull(created!.Location); + Assert.True(created.Location.Id > 0); + Assert.NotEqual(999999L, created.Location.Id); + Assert.Equal("alice", created.ClientId); + } + + [Fact] + public async Task PostQuery_without_location_returns_bad_request() + { + _fixture.ResetAndSeedRdvQueryGraph(); + using var http = NewClient(subject: "alice"); + + var createPayload = new + { + ActivityCode = "dev", + PerformerId = "alice", + Consent = true, + EventDate = DateTime.UtcNow.AddDays(1), + Reason = "Rendez-vous sans location", + Status = QueryStatus.Inserted, + }; + + var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode); + } + + [Fact] + public async Task PostQuery_with_unknown_location_id_and_missing_address_returns_bad_request() + { + _fixture.ResetAndSeedRdvQueryGraph(); + using var http = NewClient(subject: "alice"); + + var createPayload = new + { + ActivityCode = "dev", + PerformerId = "alice", + Consent = true, + EventDate = DateTime.UtcNow.AddDays(1), + Location = new + { + Id = 777777L, + Address = "", + Latitude = 0.0, + Longitude = 0.0, + }, + Reason = "Rendez-vous location invalide", + Status = QueryStatus.Inserted, + }; + + var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode); + } } diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index 5f9349559..219431b7e 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -7,6 +7,8 @@ using Yavsc.Billing; using Yavsc.Helpers; using Yavsc.ViewModels; using Yavsc.Models.Billing; +using Yavsc.Models.Haircut; +using Yavsc.Models.Workflow; using Yavsc.Server.Models.FileSystem; namespace Yavsc.ApiControllers @@ -121,12 +123,38 @@ namespace Yavsc.ApiControllers WorkflowHelpers.ConfigureBillingService(); } - var commands = dbContext.Set() + // Query known derived types explicitly so legacy rows with + // invalid/empty discriminator values are naturally ignored. + var rdvCommands = dbContext.Set() .AsNoTracking() .Where(q => q.PerformerId == uid) .Where(q => q.Status == QueryStatus.Inserted || q.Status == QueryStatus.Accepted || q.Status == QueryStatus.InProgress) + .Cast() + .ToList(); + + var hairCommands = dbContext.Set() + .AsNoTracking() + .Where(q => q.PerformerId == uid) + .Where(q => q.Status == QueryStatus.Inserted + || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.InProgress) + .Cast() + .ToList(); + + var hairMultiCommands = dbContext.Set() + .AsNoTracking() + .Where(q => q.PerformerId == uid) + .Where(q => q.Status == QueryStatus.Inserted + || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.InProgress) + .Cast() + .ToList(); + + var commands = rdvCommands + .Concat(hairCommands) + .Concat(hairMultiCommands) .OrderByDescending(q => q.DateModified) .ThenByDescending(q => q.Id) .ToList(); diff --git a/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs deleted file mode 100644 index 293cca9ae..000000000 --- a/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Yavsc.Controllers -{ - using System; - using Yavsc.Models; - using Yavsc.Models.Workflow; - using Yavsc.Models.Billing; - using Yavsc.Abstract.Identity; - using Microsoft.EntityFrameworkCore; - using Yavsc.Server.Helpers; - - [Authorize] - [Produces("application/json")] - [Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")] - public class BookQueryApiController : Controller - { - private ApplicationDbContext _context; - private ILogger _logger; - - public BookQueryApiController(ApplicationDbContext context, ILoggerFactory loggerFactory) - { - _context = context; - _logger = loggerFactory.CreateLogger(); - } - - // GET: api/BookQueryApi - /// - /// Book queries, by creation order - /// - /// returned Ids must be lower than this value - /// book queries - [HttpGet] - public IEnumerable GetCommands(long maxId=long.MaxValue) - { - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - var now = DateTime.UtcNow; - - var result = _context.RdvQueries.Include(c => c.Location). - Include(c => c.Client).Where(c => c.PerformerId == uid && c.Id < maxId && c.EventDate > now - && c.ValidationDate == null). - Select(c => new RdvQueryProviderInfo - { - Client = new ClientProviderInfo { - UserName = c.Client.UserName, - UserId = c.ClientId, - Avatar = c.Client.Avatar }, - Location = c.Location, - EventDate = c.EventDate, - Id = c.Id, - Previsional = c.Provisional, - Reason = c.Reason, - ActivityCode = c.ActivityCode, - BillingCode = BillingCodes.Rdv - }). - OrderBy(c=>c.Id). - Take(25); - return result; - } - - // GET: api/BookQueryApi/5 - [HttpGet("{id}", Name = "GetBookQuery")] - public IActionResult GetBookQuery([FromRoute] long id) - { - - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - - RdvQuery bookQuery = _context.RdvQueries.Where(c => c.ClientId == uid || c.PerformerId == uid).Single(m => m.Id == id); - - if (bookQuery == null) - { - return NotFound(); - } - - return Ok(bookQuery); - } - - // PUT: api/BookQueryApi/5 - [HttpPut("{id}")] - public IActionResult PutBookQuery(long id, [FromBody] RdvQuery bookQuery) - { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - if (id != bookQuery.Id) - { - return BadRequest(); - } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (bookQuery.ClientId != uid) - return NotFound(); - - _context.Entry(bookQuery).State = EntityState.Modified; - - try - { - _context.SaveChanges(User.GetUserId()); - } - catch (DbUpdateConcurrencyException) - { - if (!BookQueryExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return new StatusCodeResult(StatusCodes.Status204NoContent); - } - - // POST: api/BookQueryApi - [HttpPost] - public IActionResult PostBookQuery([FromBody] RdvQuery bookQuery) - { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (bookQuery.ClientId != uid) - { - ModelState.AddModelError("ClientId", "You must be the client at creating a book query"); - return new BadRequestObjectResult(ModelState); - } - _context.RdvQueries.Add(bookQuery); - try - { - _context.SaveChanges(User.GetUserId()); - } - catch (DbUpdateException) - { - if (BookQueryExists(bookQuery.Id)) - { - return new StatusCodeResult(StatusCodes.Status409Conflict); - } - else - { - throw; - } - } - - return CreatedAtRoute("GetBookQuery", new { id = bookQuery.Id }, bookQuery); - } - - // DELETE: api/BookQueryApi/5 - [HttpDelete("{id}")] - public IActionResult DeleteBookQuery(long id) - { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - RdvQuery bookQuery = _context.RdvQueries.Single(m => m.Id == id); - - if (bookQuery == null) - { - return NotFound(); - } - if (bookQuery.ClientId != uid) return NotFound(); - - _context.RdvQueries.Remove(bookQuery); - _context.SaveChanges(User.GetUserId()); - - return Ok(bookQuery); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _context.Dispose(); - } - base.Dispose(disposing); - } - - private bool BookQueryExists(long id) - { - return _context.RdvQueries.Count(e => e.Id == id) > 0; - } - } -} diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs index 48e3be2e1..06256a987 100644 --- a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs @@ -1,8 +1,13 @@ +#nullable enable annotations + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Npgsql; using Yavsc.Models; using Yavsc.Models.Billing; +using Yavsc.Models.Relationship; using Yavsc.Models.Workflow; using Yavsc.Server.Helpers; @@ -83,30 +88,32 @@ public class RdvQueryApiController : Controller return BadRequest(ModelState); } - if (query.Location is not null) + if (query.Location is null) { - var existingLocation = await _context.Locations.FirstOrDefaultAsync( - x => x.Address == query.Location.Address - && x.Longitude == query.Location.Longitude - && x.Latitude == query.Location.Latitude, - cancellationToken); - - if (existingLocation is not null) - { - query.Location = existingLocation; - } - else - { - _context.Attach(query.Location); - } + return BadRequest(new { Error = "location is required" }); } - _context.RdvQueries.Add(query); + var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken); + if (resolvedLocation is null) + { + return BadRequest(new { Error = "location payload is invalid" }); + } + + await PersistLocationIfNeededAsync(resolvedLocation, uid, cancellationToken); + + query.Location = resolvedLocation; + + var addedEntry = _context.RdvQueries.Add(query); + EnsureLocationForeignKey(addedEntry, resolvedLocation.Id); try { await _context.SaveChangesAsync(User.GetUserId(), cancellationToken); } + catch (DbUpdateException ex) when (IsLocationForeignKeyViolation(ex)) + { + return BadRequest(new { Error = "location reference is invalid" }); + } catch (DbUpdateException) { if (QueryExists(query.Id)) @@ -149,17 +156,16 @@ public class RdvQueryApiController : Controller 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; + var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken); if (resolvedLocation is null) { - _context.Attach(query.Location); + return BadRequest(new { Error = "location payload is invalid" }); } + + await PersistLocationIfNeededAsync(resolvedLocation, uid, cancellationToken); + + existing.Location = resolvedLocation; + EnsureLocationForeignKey(_context.Entry(existing), resolvedLocation.Id); } try @@ -175,6 +181,10 @@ public class RdvQueryApiController : Controller throw; } + catch (DbUpdateException ex) when (IsLocationForeignKeyViolation(ex)) + { + return BadRequest(new { Error = "location reference is invalid" }); + } return NoContent(); } @@ -208,6 +218,78 @@ public class RdvQueryApiController : Controller return _context.RdvQueries.Any(e => e.Id == id); } + private async Task ResolveLocationAsync(Location postedLocation, CancellationToken cancellationToken) + { + if (postedLocation.Id > 0) + { + var byId = await _context.Locations + .FirstOrDefaultAsync(x => x.Id == postedLocation.Id, cancellationToken); + if (byId is not null) + { + return byId; + } + } + + if (string.IsNullOrWhiteSpace(postedLocation.Address)) + { + return null; + } + + var existingByCoordinates = await _context.Locations.FirstOrDefaultAsync( + x => x.Address == postedLocation.Address + && x.Longitude == postedLocation.Longitude + && x.Latitude == postedLocation.Latitude, + cancellationToken); + + if (existingByCoordinates is not null) + { + return existingByCoordinates; + } + + // Treat unknown location ids as client-side placeholders and insert a new row. + postedLocation.Id = 0; + _context.Locations.Add(postedLocation); + return postedLocation; + } + + private async Task PersistLocationIfNeededAsync(Location location, string userId, CancellationToken cancellationToken) + { + if (_context.Entry(location).State != EntityState.Added) + { + return; + } + + await _context.SaveChangesAsync(userId, cancellationToken); + } + + private static bool IsLocationForeignKeyViolation(DbUpdateException ex) + { + if (ex.InnerException is not PostgresException pg) + { + return false; + } + + return pg.SqlState == PostgresErrorCodes.ForeignKeyViolation + && string.Equals(pg.ConstraintName, "FK_NominativeServiceCommand_Locations_LocationId", StringComparison.Ordinal); + } + + private static void EnsureLocationForeignKey(EntityEntry entry, long locationId) + { + SetFkIfPresent(entry, "LocationId", locationId); + SetFkIfPresent(entry, "RdvQuery_LocationId", locationId); + } + + private static void SetFkIfPresent(EntityEntry entry, string propertyName, long value) + { + var property = entry.Metadata.FindProperty(propertyName); + if (property is null) + { + return; + } + + entry.Property(propertyName).CurrentValue = value; + } + private static DateTime EnsureUtc(DateTime value) { return value.Kind switch diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 8117fb1d4..5f7ef25c0 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -62,6 +62,9 @@ internal class Program services.AddAuthentication("Bearer") .AddYavscJwtBearer(builder.Configuration); + services.AddSignalR(); + services.AddSingleton(); + // DbContextBuilder services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString( diff --git a/src/Yavsc.Org/Migrations/20260913022000_cleanupLegacyNominativeServiceCommandLocationId.cs b/src/Yavsc.Org/Migrations/20260913022000_cleanupLegacyNominativeServiceCommandLocationId.cs new file mode 100644 index 000000000..dc68aa9f1 --- /dev/null +++ b/src/Yavsc.Org/Migrations/20260913022000_cleanupLegacyNominativeServiceCommandLocationId.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Yavsc.Models; + +#nullable disable + +namespace Yavsc.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260913022000_cleanupLegacyNominativeServiceCommandLocationId")] + public partial class cleanupLegacyNominativeServiceCommandLocationId : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +UPDATE ""NominativeServiceCommand"" +SET ""RdvQuery_LocationId"" = COALESCE(""RdvQuery_LocationId"", ""LocationId"") +WHERE ""Discriminator"" = 'RdvQuery' + AND ""LocationId"" IS NOT NULL; +"); + + migrationBuilder.DropForeignKey( + name: "FK_NominativeServiceCommand_Locations_LocationId", + table: "NominativeServiceCommand"); + + migrationBuilder.DropIndex( + name: "IX_NominativeServiceCommand_LocationId", + table: "NominativeServiceCommand"); + + migrationBuilder.DropColumn( + name: "LocationId", + table: "NominativeServiceCommand"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LocationId", + table: "NominativeServiceCommand", + type: "bigint", + nullable: true); + + migrationBuilder.Sql(@" +UPDATE ""NominativeServiceCommand"" +SET ""LocationId"" = ""RdvQuery_LocationId"" +WHERE ""Discriminator"" = 'RdvQuery' + AND ""RdvQuery_LocationId"" IS NOT NULL; +"); + + migrationBuilder.CreateIndex( + name: "IX_NominativeServiceCommand_LocationId", + table: "NominativeServiceCommand", + column: "LocationId"); + + migrationBuilder.AddForeignKey( + name: "FK_NominativeServiceCommand_Locations_LocationId", + table: "NominativeServiceCommand", + column: "LocationId", + principalTable: "Locations", + principalColumn: "Id"); + } + } +} \ No newline at end of file diff --git a/src/Yavsc.Server/Interfaces/IConnexionManager.cs b/src/Yavsc.Server/Interfaces/IConnexionManager.cs index 46374d40a..434054249 100644 --- a/src/Yavsc.Server/Interfaces/IConnexionManager.cs +++ b/src/Yavsc.Server/Interfaces/IConnexionManager.cs @@ -19,7 +19,7 @@ namespace Yavsc.Services bool Kick(string cxId, string userName, string roomName, string reason); bool Op(string roomName, string userName); - bool Deop(string roomName, string userName); + bool DeOp(string roomName, string userName); bool Hop(string roomName, string userName); bool DeHop(string roomName, string userName); bool TryGetChanInfo(string room, out ChatRoomInfo channelInfo); diff --git a/src/Yavsc.Org/Services/ChatHubConnexionManager.cs b/src/Yavsc.Server/Services/HubConnectionManager.cs similarity index 81% rename from src/Yavsc.Org/Services/ChatHubConnexionManager.cs rename to src/Yavsc.Server/Services/HubConnectionManager.cs index a97826616..05ed49dda 100644 --- a/src/Yavsc.Org/Services/ChatHubConnexionManager.cs +++ b/src/Yavsc.Server/Services/HubConnectionManager.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; using Yavsc.Abstract.Chat; using Yavsc.Models; using Yavsc.ViewModels.Chat; @@ -119,10 +121,10 @@ namespace Yavsc.Services public bool Part(string cxId, string roomName, string reason) { - ChatRoomInfo chanInfo; - if (Channels.TryGetValue(roomName, out chanInfo)) + ChatRoomInfo channelInfo; + if (Channels.TryGetValue(roomName, out channelInfo)) { - if (!chanInfo.Users.Contains(cxId)) + if (!channelInfo.Users.Contains(cxId)) { // TODO NotifyErrorToCaller(roomName, "you didn't join."); return false; @@ -130,11 +132,11 @@ namespace Yavsc.Services // FIXME only remove cx, not username, // as long as he might be connected // from another device, to the same room - chanInfo.Users.Remove(cxId); - if (chanInfo.Users.Count == 0) + channelInfo.Users.Remove(cxId); + if (channelInfo.Users.Count == 0) { - ChatRoomInfo deadchanInfo; - if (Channels.TryRemove(roomName, out deadchanInfo)) + ChatRoomInfo deadChannelInfo; + if (Channels.TryRemove(roomName, out deadChannelInfo)) { var room = _dbContext.ChatRoom.FirstOrDefault(r => r.Name == roomName); room.LatestJoinPart = DateTime.UtcNow; @@ -155,67 +157,67 @@ namespace Yavsc.Services var userName = ChatUserNames[cxId]; _logger.LogInformation($"Join: {userName}=>{roomName}"); - ChatRoomInfo chanInfo; + ChatRoomInfo channelInfo; // if channel already is open if (Channels.ContainsKey(roomName)) { - if (Channels.TryGetValue(roomName, out chanInfo)) + if (Channels.TryGetValue(roomName, out channelInfo)) { if (IsPresent(roomName, userName)) { // TODO implement some unique connection sharing protocol // between all terminals from a single user. - return chanInfo; + return channelInfo; } else { if (IsCop(userName)) { - chanInfo.Ops.Add(cxId); + channelInfo.Ops.Add(cxId); } else{ - chanInfo.Users.Add(cxId); + channelInfo.Users.Add(cxId); } _logger.LogInformation($"existing room joint: {userName}=>{roomName}"); if (!ChatRoomPresence[userName].Contains(roomName)) ChatRoomPresence[userName].Add(roomName); - return chanInfo; + return channelInfo; } } else { - string msg = "room seemd to be avaible ... but we could get no info on it."; + string msg = "room seemed to be available ... but we could get no info on it."; _errorHandler(roomName, msg); return null; } } // room was closed. var room = _dbContext.ChatRoom.FirstOrDefault(r => r.Name == roomName); - chanInfo = new ChatRoomInfo(); + channelInfo = new ChatRoomInfo(); if (room != null) { - chanInfo.Topic = room.Topic; - chanInfo.Name = room.Name; - chanInfo.Users.Add(cxId); + channelInfo.Topic = room.Topic; + channelInfo.Name = room.Name; + channelInfo.Users.Add(cxId); } else { // a first join, we create it. - chanInfo.Name = roomName; - chanInfo.Topic = _localizer.GetString(ChatHubConstants.JustCreatedBy)+userName; - chanInfo.Ops.Add(cxId); + channelInfo.Name = roomName; + channelInfo.Topic = _localizer.GetString(ChatHubConstants.JustCreatedBy)+userName; + channelInfo.Ops.Add(cxId); } - if (Channels.TryAdd(roomName, chanInfo)) + if (Channels.TryAdd(roomName, channelInfo)) { ChatRoomPresence[userName].Add(roomName); _logger.LogInformation("new room joint"); - return (chanInfo); + return (channelInfo); } else { - string msg = "Chan create failed unexpectly..."; + string msg = "Chan create failed unexpectedly..."; _errorHandler(roomName, msg); return null; } @@ -226,7 +228,7 @@ namespace Yavsc.Services throw new System.NotImplementedException(); } - public bool Deop(string roomName, string userName) + public bool DeOp(string roomName, string userName) { throw new System.NotImplementedException(); } @@ -246,9 +248,9 @@ namespace Yavsc.Services return ChatUserNames[cxId]; } - public bool TryGetChanInfo(string room, out ChatRoomInfo chanInfo) + public bool TryGetChanInfo(string room, out ChatRoomInfo channelInfo) { - return Channels.TryGetValue(room, out chanInfo); + return Channels.TryGetValue(room, out channelInfo); } public IEnumerable ListChannels(string pattern) @@ -277,22 +279,22 @@ namespace Yavsc.Services public bool Kick(string cxId, string userName, string roomName, string reason) { - ChatRoomInfo chanInfo; + ChatRoomInfo channelInfo; if (!Channels.ContainsKey(roomName)) { _errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabNoSuchChan).ToString()); return false; } - if (!Channels.TryGetValue(roomName, out chanInfo)) + if (!Channels.TryGetValue(roomName, out channelInfo)) { _errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabNoSuchChan).ToString()); return false; } var kickerName = GetUserName(cxId); - if (!chanInfo.Ops.Contains(cxId)) - if (!chanInfo.Hops.Contains(cxId)) + if (!channelInfo.Ops.Contains(cxId)) + if (!channelInfo.Hops.Contains(cxId)) { _errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabYouNotOp).ToString()); return false; @@ -303,9 +305,9 @@ namespace Yavsc.Services _errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabNoSuchUser).ToString()); return false; } - var ucxs = GetConnexionIds(userName); - if (chanInfo.Hops.Contains(cxId)) - if (chanInfo.Ops.Any(c => ucxs.Contains(c))) + var userConnectionIds = GetConnexionIds(userName); + if (channelInfo.Hops.Contains(cxId)) + if (channelInfo.Ops.Any(c => userConnectionIds.Contains(c))) { _errorHandler(roomName, _localizer.GetString(ChatHubConstants.HopWontKickOp).ToString()); return false; @@ -317,15 +319,15 @@ namespace Yavsc.Services } // all good, time to kick :-) - foreach (var ucx in ucxs) { - if (chanInfo.Users.Contains(ucx)) - chanInfo.Users.Remove(ucx); + foreach (var ucx in userConnectionIds) { + if (channelInfo.Users.Contains(ucx)) + channelInfo.Users.Remove(ucx); - else if (chanInfo.Ops.Contains(ucx)) - chanInfo.Ops.Remove(ucx); + else if (channelInfo.Ops.Contains(ucx)) + channelInfo.Ops.Remove(ucx); - else if (chanInfo.Hops.Contains(ucx)) - chanInfo.Hops.Remove(ucx); + else if (channelInfo.Hops.Contains(ucx)) + channelInfo.Hops.Remove(ucx); } return true;