Utopia-Canteen-System/Api/CanteenBackendHost.cs

517 lines
20 KiB
C#

using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using UtopiaCanteen.Shared;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.Api;
/// <summary>
/// Central server HTTP API (backend). Scanner and settings UIs call this; only this layer touches SQLite and HRMS.
/// </summary>
public static class CanteenBackendHost
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public static async Task RunAsync(CanteenBackendServices backend, string listenUrls, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(listenUrls))
listenUrls = "http://0.0.0.0:5000/";
var prefixes = listenUrls
.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(NormalizePrefix)
.Where(p => !string.IsNullOrEmpty(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (prefixes.Length == 0)
prefixes = new[] { "http://+:5000/" };
using var listener = new HttpListener();
foreach (var prefix in prefixes)
listener.Prefixes.Add(prefix);
listener.Start();
Logger.Log(new Exception($"Canteen backend API listening: {string.Join(", ", prefixes)}"), "CanteenBackendHost");
try
{
while (!cancellationToken.IsCancellationRequested)
{
HttpListenerContext ctx;
try
{
ctx = await listener.GetContextAsync().ConfigureAwait(false);
}
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
_ = Task.Run(() => ProcessRequestAsync(ctx, backend), cancellationToken);
}
}
finally
{
listener.Stop();
listener.Close();
}
}
private static async Task ProcessRequestAsync(HttpListenerContext ctx, CanteenBackendServices backend)
{
try
{
AddCorsHeaders(ctx.Response);
if (string.Equals(ctx.Request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
ctx.Response.StatusCode = 204;
ctx.Response.Close();
return;
}
var path = ctx.Request.Url?.AbsolutePath?.TrimEnd('/') ?? string.Empty;
var method = ctx.Request.HttpMethod ?? "GET";
var rfid = backend.Rfid;
if (method == "GET" && path.Equals("/api/health", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(ctx, new HealthResponse
{
Status = "ok",
Mode = "server",
Utc = DateTime.UtcNow
}).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/rfid/scan", StringComparison.OrdinalIgnoreCase))
{
await HandleScanAsync(ctx, rfid).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/cache/sync-now", StringComparison.OrdinalIgnoreCase))
{
await HandleCacheSyncAsync(ctx, backend).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/orders/sync-now", StringComparison.OrdinalIgnoreCase))
{
await HandleOrdersSyncAsync(ctx, backend).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
{
await HandleMealSchedulesAsync(ctx, backend, path, method).ConfigureAwait(false);
return;
}
if (method == "GET")
{
if (path.Equals("/api/cache/status", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(ctx, new CacheStatusResponse
{
LastEmployeeRfidCacheSyncUtc = backend.Config.GetLastEmployeeRfidCacheSyncUtc(),
LastMealMenuCacheSyncUtc = backend.Config.GetLastMealMenuCacheSyncUtc()
}).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/scans/today", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(ctx, rfid.GetScansForToday().Select(ApiDtoMapper.ToRecentScanDto).ToList()).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/scans/last", StringComparison.OrdinalIgnoreCase))
{
var last = rfid.GetLastScan();
await WriteJsonAsync(ctx, last == null ? null : ApiDtoMapper.ToRecentScanDto(last)).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/rfid/scans/recent", StringComparison.OrdinalIgnoreCase))
{
var count = ParseQueryInt(ctx.Request.Url?.Query, "count", 4);
var list = rfid.GetLastScans(count).Select(ApiDtoMapper.ToRecentScanDto).ToList();
await WriteJsonAsync(ctx, list).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/stats/today", StringComparison.OrdinalIgnoreCase))
{
var n = await rfid.GetTodayScanCountAsync().ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/stats/total", StringComparison.OrdinalIgnoreCase))
{
var n = await rfid.GetTotalScanCountAsync().ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/rfid/stats/today-for-card", StringComparison.OrdinalIgnoreCase))
{
var cardId = ParseQueryString(ctx.Request.Url?.Query, "cardId");
var n = await rfid.GetTodayScanCountForCardAsync(cardId ?? string.Empty).ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/rfid/stats/total-for-card", StringComparison.OrdinalIgnoreCase))
{
var cardId = ParseQueryString(ctx.Request.Url?.Query, "cardId");
var n = await rfid.GetTotalScanCountForCardAsync(cardId ?? string.Empty).ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.Equals("/api/employees/location-site", StringComparison.OrdinalIgnoreCase))
{
await HandleEmployeeLocationSiteAsync(ctx).ConfigureAwait(false);
return;
}
}
await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.ProcessRequestAsync");
try
{
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
catch
{
// Ignore
}
}
}
private static async Task HandleCacheSyncAsync(HttpListenerContext ctx, CanteenBackendServices backend)
{
var started = DateTime.UtcNow;
try
{
var result = await backend.RunCacheSyncExclusiveAsync(async () =>
{
if (string.IsNullOrWhiteSpace(backend.Config.GetHrmsLookupConnectionString()))
{
return ApiDtoMapper.ToManualSync(false, "MySQL connection string is not configured.", started, DateTime.UtcNow);
}
var sync = await backend.OfflineCacheSync.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
var completed = DateTime.UtcNow;
if (!sync.Success)
{
return ApiDtoMapper.ToManualSync(false, sync.ErrorMessage ?? "Cache sync failed.", started, completed, sync.ErrorMessage);
}
var emp = sync.EmployeeSync?.UpsertedCount ?? 0;
var meal = sync.MealMenuSync;
var details =
$"Employee RFID tags: {emp}; " +
$"Meal schedules: {meal?.MealScheduleCount ?? 0}; " +
$"Menu weeks: {meal?.LunchMenuWeekCount ?? 0}; " +
$"Menu items: {meal?.LunchMenuItemCount ?? 0}; " +
$"Menu catalog: {meal?.MenuItemCount ?? 0}.";
return ApiDtoMapper.ToManualSync(true, "Employee and menu cache synced successfully.", started, completed, details);
}).ConfigureAwait(false);
await WriteJsonAsync(ctx, result, result.Success ? 200 : 500).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.HandleCacheSyncAsync");
await WriteJsonAsync(ctx, ApiDtoMapper.ToManualSync(false, ex.Message, started, DateTime.UtcNow, ex.ToString()), 500).ConfigureAwait(false);
}
}
private static async Task HandleOrdersSyncAsync(HttpListenerContext ctx, CanteenBackendServices backend)
{
var started = DateTime.UtcNow;
try
{
var result = await backend.RunOrderSyncExclusiveAsync(async () =>
{
if (string.IsNullOrWhiteSpace(backend.Config.GetMySqlConnectionString()))
{
return ApiDtoMapper.ToManualSync(false, "Production MySQL connection is not configured.", started, DateTime.UtcNow);
}
await backend.ProductionSync.SyncNowAsync().ConfigureAwait(false);
var completed = DateTime.UtcNow;
return ApiDtoMapper.ToManualSync(
true,
"Pending orders posted to production.",
started,
completed,
"Unsynced lunch_order_transactions were posted and marked IsSynced where successful.");
}).ConfigureAwait(false);
await WriteJsonAsync(ctx, result, result.Success ? 200 : 500).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.HandleOrdersSyncAsync");
await WriteJsonAsync(ctx, ApiDtoMapper.ToManualSync(false, ex.Message, started, DateTime.UtcNow, ex.ToString()), 500).ConfigureAwait(false);
}
}
private static async Task HandleScanAsync(HttpListenerContext ctx, RfidService rfid)
{
ScanRequest? dto;
using (var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding))
{
var body = await reader.ReadToEndAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(body))
{
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
return;
}
dto = JsonSerializer.Deserialize<ScanRequest>(body, JsonOptions);
}
if (dto == null || string.IsNullOrWhiteSpace(dto.CardId))
{
await WriteJsonAsync(ctx, new { message = "cardId is required." }, 400).ConfigureAwait(false);
return;
}
var remoteIp = ctx.Request.RemoteEndPoint?.Address?.ToString() ?? string.Empty;
if (remoteIp == "::1")
remoteIp = "127.0.0.1";
var ip = string.IsNullOrWhiteSpace(dto.IpAddress) ? remoteIp : dto.IpAddress!.Trim();
var clientCtx = new RfidScanClientContext
{
DeviceId = dto.DeviceId?.Trim() ?? string.Empty,
SiteId = dto.SiteId?.Trim() ?? string.Empty,
IpAddress = ip
};
var result = rfid.ProcessScanDetailed(dto.CardId.Trim(), clientCtx);
string? mealLabel = null;
string? mealItems = null;
double totalPrice = 0;
if (result.Success)
{
var last = rfid.GetLastScan();
if (last != null)
{
mealLabel = last.MealLabel;
mealItems = last.MealItems;
totalPrice = last.TotalPrice;
}
}
var response = ApiDtoMapper.ToScanResponse(result, mealLabel, mealItems, totalPrice);
await WriteJsonAsync(ctx, response).ConfigureAwait(false);
}
private static void AddCorsHeaders(HttpListenerResponse response)
{
response.Headers.Add("Access-Control-Allow-Origin", "*");
response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.Headers.Add("Access-Control-Allow-Headers", "Content-Type");
}
private static async Task WriteJsonAsync(HttpListenerContext ctx, object? payload, int statusCode = 200)
{
var json = JsonSerializer.Serialize(payload, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.StatusCode = statusCode;
ctx.Response.ContentType = "application/json; charset=utf-8";
ctx.Response.ContentLength64 = bytes.Length;
AddCorsHeaders(ctx.Response);
await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false);
ctx.Response.Close();
}
private static async Task HandleEmployeeLocationSiteAsync(HttpListenerContext ctx)
{
var employeeId = ParseQueryString(ctx.Request.Url?.Query, "employeeId")?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(employeeId))
{
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse(), 400).ConfigureAwait(false);
return;
}
try
{
var dbFactory = new DbContextFactory();
await using var db = dbFactory.CreateDbContext();
var siteId = await db.EmployeeRfidTagCache
.AsNoTracking()
.Where(x => x.ParentDocumentType == "Employee")
.Where(x => x.EmployeeSerialNumber == employeeId)
.Where(x => !string.IsNullOrEmpty(x.LocationSiteId))
.Select(x => x.LocationSiteId)
.FirstOrDefaultAsync()
.ConfigureAwait(false);
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse
{
LocationSiteId = string.IsNullOrWhiteSpace(siteId) ? null : siteId.Trim()
}).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.HandleEmployeeLocationSiteAsync");
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse(), 500).ConfigureAwait(false);
}
}
private static async Task HandleMealSchedulesAsync(HttpListenerContext ctx, CanteenBackendServices backend, string path, string method)
{
var mealSvc = new ProductionMealScheduleService(backend.Config);
if (method == "GET" && path.Equals("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
{
var list = await mealSvc.GetAllSchedulesAsync().ConfigureAwait(false);
await WriteJsonAsync(ctx, list.Select(ApiDtoMapper.ToMealScheduleDto).ToList()).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
{
var dto = await ReadJsonBodyAsync<MealScheduleDto>(ctx).ConfigureAwait(false);
if (dto == null)
{
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
return;
}
try
{
var entity = ApiDtoMapper.ToMealSchedule(dto);
var id = await mealSvc.CreateAsync(entity).ConfigureAwait(false);
entity.Id = id;
await WriteJsonAsync(ctx, ApiDtoMapper.ToMealScheduleDto(entity), 201).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.CreateMealSchedule");
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
return;
}
var scheduleId = TryParseMealScheduleId(path);
if (scheduleId.HasValue)
{
if (method == "PUT")
{
var dto = await ReadJsonBodyAsync<MealScheduleDto>(ctx).ConfigureAwait(false);
if (dto == null)
{
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
return;
}
try
{
var entity = ApiDtoMapper.ToMealSchedule(dto);
entity.Id = scheduleId.Value;
await mealSvc.UpdateAsync(entity).ConfigureAwait(false);
await WriteJsonAsync(ctx, ApiDtoMapper.ToMealScheduleDto(entity)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.UpdateMealSchedule");
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
return;
}
if (method == "DELETE")
{
try
{
await mealSvc.DeleteAsync(scheduleId.Value).ConfigureAwait(false);
await WriteJsonAsync(ctx, new { success = true }).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.DeleteMealSchedule");
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
return;
}
}
await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false);
}
private static long? TryParseMealScheduleId(string path)
{
const string prefix = "/api/meal-schedules/";
if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return null;
var idPart = path[prefix.Length..];
return long.TryParse(idPart, out var id) ? id : null;
}
private static async Task<T?> ReadJsonBodyAsync<T>(HttpListenerContext ctx) where T : class
{
using var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding);
var body = await reader.ReadToEndAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(body))
return null;
return JsonSerializer.Deserialize<T>(body, JsonOptions);
}
private static string NormalizePrefix(string url)
{
var u = url.Trim();
if (!u.EndsWith('/'))
u += "/";
if (u.Contains("0.0.0.0", StringComparison.Ordinal))
u = u.Replace("0.0.0.0", "+", StringComparison.Ordinal);
return u;
}
private static int ParseQueryInt(string? query, string key, int defaultValue)
{
var s = ParseQueryString(query, key);
return int.TryParse(s, out var n) ? n : defaultValue;
}
private static string? ParseQueryString(string? query, string key)
{
if (string.IsNullOrEmpty(query))
return null;
var q = query.TrimStart('?');
foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var kv = part.Split('=', 2);
if (kv.Length > 0 && string.Equals(Uri.UnescapeDataString(kv[0]), key, StringComparison.OrdinalIgnoreCase))
return kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty;
}
return null;
}
}