2026-09-13 03:09:12 +01:00
|
|
|
#nullable enable annotations
|
|
|
|
|
|
2026-08-31 03:37:09 +01:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
2026-09-13 03:09:12 +01:00
|
|
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
|
|
|
|
using Npgsql;
|
2026-08-31 03:37:09 +01:00
|
|
|
using Yavsc.Models;
|
|
|
|
|
using Yavsc.Models.Billing;
|
2026-09-13 03:09:12 +01:00
|
|
|
using Yavsc.Models.Relationship;
|
2026-08-31 03:37:09 +01:00
|
|
|
using Yavsc.Models.Workflow;
|
|
|
|
|
using Yavsc.Server.Helpers;
|
|
|
|
|
|
|
|
|
|
namespace Yavsc.Controllers;
|
|
|
|
|
|
|
|
|
|
[Authorize]
|
|
|
|
|
[Produces("application/json")]
|
|
|
|
|
[Route(Constants.APIPrefix + "/billing/" + BillingCodes.Rdv)]
|
|
|
|
|
public class RdvQueryApiController : Controller
|
|
|
|
|
{
|
|
|
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
|
|
|
|
|
|
public RdvQueryApiController(ApplicationDbContext context)
|
|
|
|
|
{
|
|
|
|
|
_context = context;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet]
|
|
|
|
|
public async Task<IActionResult> GetQueries(CancellationToken cancellationToken)
|
|
|
|
|
{
|
2026-08-31 04:15:10 +01:00
|
|
|
var uid = User.GetUserId();
|
2026-08-31 03:37:09 +01:00
|
|
|
|
|
|
|
|
var queries = await _context.RdvQueries
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
.Include(q => q.Location)
|
|
|
|
|
.Include(q => q.Client)
|
|
|
|
|
.Include(q => q.PerformerProfile)
|
|
|
|
|
.Where(q => q.ClientId == uid || q.PerformerId == uid)
|
|
|
|
|
.OrderByDescending(q => q.Id)
|
|
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
|
|
|
|
|
return Ok(queries);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("{id}", Name = "GetRdvQuery")]
|
|
|
|
|
public async Task<IActionResult> GetQuery([FromRoute] long id, CancellationToken cancellationToken)
|
|
|
|
|
{
|
2026-08-31 04:15:10 +01:00
|
|
|
var uid = User.GetUserId();
|
2026-08-31 03:37:09 +01:00
|
|
|
|
|
|
|
|
var query = await _context.RdvQueries
|
|
|
|
|
.Include(q => q.Location)
|
|
|
|
|
.Include(q => q.Client)
|
|
|
|
|
.Include(q => q.PerformerProfile)
|
|
|
|
|
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
|
|
|
|
|
|
|
|
|
|
if (query is null)
|
|
|
|
|
{
|
|
|
|
|
return NotFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
|
|
|
|
{
|
|
|
|
|
return Forbid();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Ok(query);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost]
|
|
|
|
|
public async Task<IActionResult> PostQuery([FromBody] RdvQuery query, CancellationToken cancellationToken)
|
|
|
|
|
{
|
2026-08-31 04:15:10 +01:00
|
|
|
var uid = User.GetUserId();
|
2026-09-06 19:16:41 +01:00
|
|
|
// Security: the caller always posts for themselves.
|
|
|
|
|
query.ClientId = uid;
|
2026-09-06 19:48:51 +01:00
|
|
|
query.EventDate = EnsureUtc(query.EventDate);
|
2026-08-31 03:37:09 +01:00
|
|
|
|
2026-09-06 19:16:41 +01:00
|
|
|
ModelState.Remove("Client");
|
2026-08-31 04:15:10 +01:00
|
|
|
ModelState.Remove("ClientId");
|
2026-09-06 19:16:41 +01:00
|
|
|
ModelState.Remove("UserCreated");
|
|
|
|
|
ModelState.Remove("UserModified");
|
|
|
|
|
ModelState.Remove("SelectedProfile");
|
|
|
|
|
ModelState.Remove("PerformerProfile");
|
|
|
|
|
ModelState.Remove("Context");
|
|
|
|
|
ModelState.Remove("Regularization");
|
2026-08-31 03:37:09 +01:00
|
|
|
|
|
|
|
|
if (!ModelState.IsValid)
|
|
|
|
|
{
|
|
|
|
|
return BadRequest(ModelState);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 03:09:12 +01:00
|
|
|
if (query.Location is null)
|
2026-08-31 03:37:09 +01:00
|
|
|
{
|
2026-09-13 03:09:12 +01:00
|
|
|
return BadRequest(new { Error = "location is required" });
|
|
|
|
|
}
|
2026-08-31 03:37:09 +01:00
|
|
|
|
2026-09-13 03:09:12 +01:00
|
|
|
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
|
|
|
|
|
if (resolvedLocation is null)
|
|
|
|
|
{
|
|
|
|
|
return BadRequest(new { Error = "location payload is invalid" });
|
2026-08-31 03:37:09 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-13 03:09:12 +01:00
|
|
|
await PersistLocationIfNeededAsync(resolvedLocation, uid, cancellationToken);
|
|
|
|
|
|
|
|
|
|
query.Location = resolvedLocation;
|
|
|
|
|
|
|
|
|
|
var addedEntry = _context.RdvQueries.Add(query);
|
|
|
|
|
EnsureLocationForeignKey(addedEntry, resolvedLocation.Id);
|
2026-08-31 03:37:09 +01:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
|
|
|
|
}
|
2026-09-13 03:09:12 +01:00
|
|
|
catch (DbUpdateException ex) when (IsLocationForeignKeyViolation(ex))
|
|
|
|
|
{
|
|
|
|
|
return BadRequest(new { Error = "location reference is invalid" });
|
|
|
|
|
}
|
2026-08-31 03:37:09 +01:00
|
|
|
catch (DbUpdateException)
|
|
|
|
|
{
|
|
|
|
|
if (QueryExists(query.Id))
|
|
|
|
|
{
|
|
|
|
|
return Conflict();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPut("{id}")]
|
|
|
|
|
public async Task<IActionResult> PutQuery([FromRoute] long id, [FromBody] RdvQuery query, CancellationToken cancellationToken)
|
|
|
|
|
{
|
2026-09-06 19:16:41 +01:00
|
|
|
var uid = User.GetUserId();
|
|
|
|
|
var existing = await _context.RdvQueries
|
|
|
|
|
.Include(q => q.Location)
|
|
|
|
|
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
|
2026-08-31 03:37:09 +01:00
|
|
|
|
2026-09-06 19:16:41 +01:00
|
|
|
if (existing is null)
|
2026-08-31 03:37:09 +01:00
|
|
|
{
|
2026-09-06 19:16:41 +01:00
|
|
|
return NotFound();
|
2026-08-31 03:37:09 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-06 19:16:41 +01:00
|
|
|
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
2026-08-31 03:37:09 +01:00
|
|
|
{
|
|
|
|
|
return Forbid();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 19:16:41 +01:00
|
|
|
existing.ActivityCode = query.ActivityCode;
|
|
|
|
|
existing.PerformerId = query.PerformerId;
|
|
|
|
|
existing.Consent = query.Consent;
|
2026-09-06 19:48:51 +01:00
|
|
|
existing.EventDate = EnsureUtc(query.EventDate);
|
2026-09-06 19:16:41 +01:00
|
|
|
existing.LocationType = query.LocationType;
|
|
|
|
|
existing.Reason = query.Reason;
|
|
|
|
|
existing.Status = query.Status;
|
|
|
|
|
existing.Provisional = query.Provisional;
|
|
|
|
|
|
|
|
|
|
if (query.Location is not null)
|
|
|
|
|
{
|
2026-09-13 03:09:12 +01:00
|
|
|
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
|
2026-09-06 19:16:41 +01:00
|
|
|
if (resolvedLocation is null)
|
|
|
|
|
{
|
2026-09-13 03:09:12 +01:00
|
|
|
return BadRequest(new { Error = "location payload is invalid" });
|
2026-09-06 19:16:41 +01:00
|
|
|
}
|
2026-09-13 03:09:12 +01:00
|
|
|
|
|
|
|
|
await PersistLocationIfNeededAsync(resolvedLocation, uid, cancellationToken);
|
|
|
|
|
|
|
|
|
|
existing.Location = resolvedLocation;
|
|
|
|
|
EnsureLocationForeignKey(_context.Entry(existing), resolvedLocation.Id);
|
2026-09-06 19:16:41 +01:00
|
|
|
}
|
2026-08-31 03:37:09 +01:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
catch (DbUpdateConcurrencyException)
|
|
|
|
|
{
|
|
|
|
|
if (!QueryExists(id))
|
|
|
|
|
{
|
|
|
|
|
return NotFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw;
|
|
|
|
|
}
|
2026-09-13 03:09:12 +01:00
|
|
|
catch (DbUpdateException ex) when (IsLocationForeignKeyViolation(ex))
|
|
|
|
|
{
|
|
|
|
|
return BadRequest(new { Error = "location reference is invalid" });
|
|
|
|
|
}
|
2026-08-31 03:37:09 +01:00
|
|
|
|
|
|
|
|
return NoContent();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpDelete("{id}")]
|
|
|
|
|
public async Task<IActionResult> DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
|
|
|
|
|
{
|
2026-08-31 04:15:10 +01:00
|
|
|
var uid = User.GetUserId();
|
2026-08-31 03:37:09 +01:00
|
|
|
|
|
|
|
|
var query = await _context.RdvQueries
|
|
|
|
|
.SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
|
|
|
|
|
|
|
|
|
|
if (query is null)
|
|
|
|
|
{
|
|
|
|
|
return NotFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
|
|
|
|
{
|
|
|
|
|
return Forbid();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_context.RdvQueries.Remove(query);
|
|
|
|
|
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
|
|
|
|
|
|
|
|
|
|
return Ok(query);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool QueryExists(long id)
|
|
|
|
|
{
|
|
|
|
|
return _context.RdvQueries.Any(e => e.Id == id);
|
|
|
|
|
}
|
2026-09-06 19:48:51 +01:00
|
|
|
|
2026-09-13 03:09:12 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 19:48:51 +01:00
|
|
|
private static DateTime EnsureUtc(DateTime value)
|
|
|
|
|
{
|
|
|
|
|
return value.Kind switch
|
|
|
|
|
{
|
|
|
|
|
DateTimeKind.Utc => value,
|
|
|
|
|
DateTimeKind.Local => value.ToUniversalTime(),
|
|
|
|
|
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-08-31 03:37:09 +01:00
|
|
|
}
|