got saved a RdvQuery

This commit is contained in:
Paul Schneider 2026-09-13 03:09:12 +01:00
commit cbb59f019d
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
17 changed files with 616 additions and 288 deletions

View file

@ -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)

View file

@ -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"),
};
}

View file

@ -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; }

View file

@ -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;

View file

@ -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; }
}

View file

@ -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<ApiWebServerFixture>
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<ApplicationDbContext>();
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<List<ProviderOngoingCommandDto>>(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; }

View file

@ -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<ApplicationDbContext>(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<ApplicationDbContext>(opt =>
opt.UseSqlite(sharedConnection));
builder.Services.AddDbContext<ApplicationDbContext>(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<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)

View file

@ -143,4 +143,87 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
Assert.NotNull(created);
Assert.Equal(DateTimeKind.Utc, created!.EventDate.Kind);
}
[Fact]
public async Task PostQuery_with_unknown_location_id_creates_location_and_succeeds()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(3),
Location = new
{
Id = 999999L,
Address = "4 rue du Test",
Latitude = 48.8569,
Longitude = 2.3525,
},
Reason = "Rendez-vous id location inconnu",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(createResponse.StatusCode == HttpStatusCode.Created, $"Unexpected status {(int)createResponse.StatusCode} ({createResponse.StatusCode}): {body}");
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(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);
}
}

View file

@ -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<NominativeServiceCommand>()
// Query known derived types explicitly so legacy rows with
// invalid/empty discriminator values are naturally ignored.
var rdvCommands = dbContext.Set<RdvQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList();
var hairCommands = dbContext.Set<HairCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList();
var hairMultiCommands = dbContext.Set<HairMultiCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList();
var commands = rdvCommands
.Concat(hairCommands)
.Concat(hairMultiCommands)
.OrderByDescending(q => q.DateModified)
.ThenByDescending(q => q.Id)
.ToList();

View file

@ -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<BookQueryApiController>();
}
// GET: api/BookQueryApi
/// <summary>
/// Book queries, by creation order
/// </summary>
/// <param name="maxId">returned Ids must be lower than this value</param>
/// <returns>book queries</returns>
[HttpGet]
public IEnumerable<RdvQueryProviderInfo> 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;
}
}
}

View file

@ -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<Location?> 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<RdvQuery> entry, long locationId)
{
SetFkIfPresent(entry, "LocationId", locationId);
SetFkIfPresent(entry, "RdvQuery_LocationId", locationId);
}
private static void SetFkIfPresent(EntityEntry<RdvQuery> 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

View file

@ -62,6 +62,9 @@ internal class Program
services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration);
services.AddSignalR();
services.AddSingleton<IConnexionManager, HubConnectionManager>();
// DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString(

View file

@ -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<long>(
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");
}
}
}

View file

@ -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);

View file

@ -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<ChannelShortInfo> 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;