diff --git a/Api/ApiDtoMapper.cs b/Api/ApiDtoMapper.cs new file mode 100644 index 0000000..783f822 --- /dev/null +++ b/Api/ApiDtoMapper.cs @@ -0,0 +1,149 @@ +using UtopiaCanteen.Shared; +using UtopiaCanteenSystem.Models; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.Api; + +public static class ApiDtoMapper +{ + public static EmployeeDto? ToEmployeeDto(HrmsEmployeeInfo? e) + { + if (e == null) return null; + return new EmployeeDto + { + ParentDocumentId = e.ParentDocumentId, + EmployeeId = e.EmployeeId, + UindSerial = e.UindSerial, + FunctionId = e.FunctionId, + DepartmentId = e.DepartmentId, + TagCreatedAtUtc = e.TagCreatedAtUtc, + TagCreatedBy = e.TagCreatedBy, + FirstName = e.FirstName, + MiddleName = e.MiddleName, + DepartmentTitle = e.DepartmentTitle, + DepartmentType = e.DepartmentType, + LocationSiteId = e.LocationSiteId, + GradeType = e.GradeType + }; + } + + public static HrmsEmployeeInfo ToModel(EmployeeDto e) => new() + { + ParentDocumentId = e.ParentDocumentId ?? string.Empty, + EmployeeId = e.EmployeeId ?? string.Empty, + UindSerial = e.UindSerial ?? string.Empty, + FunctionId = e.FunctionId, + DepartmentId = e.DepartmentId, + TagCreatedAtUtc = e.TagCreatedAtUtc, + TagCreatedBy = e.TagCreatedBy ?? string.Empty, + FirstName = e.FirstName ?? string.Empty, + MiddleName = e.MiddleName ?? string.Empty, + DepartmentTitle = e.DepartmentTitle ?? string.Empty, + DepartmentType = e.DepartmentType ?? string.Empty, + LocationSiteId = e.LocationSiteId ?? string.Empty, + GradeType = e.GradeType ?? string.Empty + }; + + public static ScanResponse ToScanResponse(ScanResult r, string? mealLabel = null, string? mealItems = null, double totalPrice = 0) => + new() + { + Success = r.Success, + Message = r.Message, + CooldownSecondsRemaining = r.CooldownSecondsRemaining, + Employee = ToEmployeeDto(r.EmployeeInfo), + MealSession = (int)r.MealSession, + EmployeeSiteId = r.EmployeeSiteId, + CurrentSiteId = r.CurrentSiteId, + MealLabel = mealLabel, + MealItems = mealItems, + TotalPrice = totalPrice + }; + + public static RecentScanDto ToRecentScanDto(ScanRecord r) => new() + { + Id = r.Id, + CardId = r.CardId, + ScanTime = r.ScanTime, + IsSynced = r.IsSynced, + SiteId = r.SiteId, + DeviceId = r.DeviceId, + IpAddress = r.IpAddress, + MealSessionCode = r.MealSessionCode, + ParentDocumentId = r.ParentDocumentId, + EmployeeId = r.EmployeeId, + UindSerial = r.UindSerial, + FunctionId = r.FunctionId, + DepartmentId = r.DepartmentId, + TagCreatedAtUtc = r.TagCreatedAtUtc, + TagCreatedBy = r.TagCreatedBy, + EmployeeName = r.EmployeeName, + Department = r.Department, + DepartmentType = r.DepartmentType, + MealLabel = r.MealLabel, + MealItems = r.MealItems, + TotalPrice = r.TotalPrice, + GradeType = r.grade_type + }; + + public static ScanRecord ToEntity(RecentScanDto d) => new() + { + Id = d.Id, + CardId = d.CardId ?? string.Empty, + ScanTime = d.ScanTime, + IsSynced = d.IsSynced, + SiteId = d.SiteId ?? string.Empty, + DeviceId = d.DeviceId ?? string.Empty, + IpAddress = d.IpAddress ?? string.Empty, + MealSessionCode = d.MealSessionCode, + ParentDocumentId = d.ParentDocumentId ?? string.Empty, + EmployeeId = d.EmployeeId ?? string.Empty, + UindSerial = d.UindSerial ?? string.Empty, + FunctionId = d.FunctionId, + DepartmentId = d.DepartmentId, + TagCreatedAtUtc = d.TagCreatedAtUtc, + TagCreatedBy = d.TagCreatedBy ?? string.Empty, + EmployeeName = d.EmployeeName ?? string.Empty, + Department = d.Department ?? string.Empty, + DepartmentType = d.DepartmentType ?? string.Empty, + MealLabel = d.MealLabel ?? string.Empty, + MealItems = d.MealItems ?? string.Empty, + TotalPrice = d.TotalPrice, + grade_type = d.GradeType ?? string.Empty + }; + + public static ManualSyncResponse ToManualSync(bool success, string message, DateTime? started, DateTime? completed, string? details = null) => + new() + { + Success = success, + Message = message, + StartedAt = started, + CompletedAt = completed, + Details = details + }; + + public static MealScheduleDto ToMealScheduleDto(MealSchedule s) => new() + { + Id = s.Id, + MealName = s.MealName, + LocationSiteId = s.LocationSiteId, + MealSession = s.MealSession, + StartTime = s.StartTime, + EndTime = s.EndTime, + IsActive = s.IsActive, + CreatedAt = s.CreatedAt, + UpdatedAt = s.UpdatedAt + }; + + public static MealSchedule ToMealSchedule(MealScheduleDto d) => new() + { + Id = d.Id, + MealName = d.MealName ?? string.Empty, + LocationSiteId = d.LocationSiteId ?? string.Empty, + MealSession = d.MealSession, + StartTime = d.StartTime ?? string.Empty, + EndTime = d.EndTime ?? string.Empty, + IsActive = d.IsActive, + CreatedAt = d.CreatedAt, + UpdatedAt = d.UpdatedAt + }; +} diff --git a/Api/BackendApiDtos.cs b/Api/BackendApiDtos.cs new file mode 100644 index 0000000..3ddd069 --- /dev/null +++ b/Api/BackendApiDtos.cs @@ -0,0 +1,23 @@ +namespace UtopiaCanteenSystem.Api; + +public sealed class HealthResponseDto +{ + public string Status { get; set; } = "ok"; + public string Mode { get; set; } = "server"; + public DateTime Utc { get; set; } +} + +public sealed class BackendJobResponseDto +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? Details { get; set; } +} + +public sealed class CacheStatusResponseDto +{ + public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; } + public DateTime? LastMealMenuCacheSyncUtc { get; set; } +} diff --git a/Api/CanteenBackendHost.cs b/Api/CanteenBackendHost.cs new file mode 100644 index 0000000..7bf5101 --- /dev/null +++ b/Api/CanteenBackendHost.cs @@ -0,0 +1,516 @@ +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; + +/// +/// Central server HTTP API (backend). Scanner and settings UIs call this; only this layer touches SQLite and HRMS. +/// +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(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(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(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 ReadJsonBodyAsync(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(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; + } +} diff --git a/Api/CanteenLocalApiHost.cs b/Api/CanteenLocalApiHost.cs deleted file mode 100644 index f99c783..0000000 --- a/Api/CanteenLocalApiHost.cs +++ /dev/null @@ -1,263 +0,0 @@ -using System.IO; -using System.Net; -using System.Text; -using System.Text.Json; -using UtopiaCanteenSystem.Models; -using UtopiaCanteenSystem.Services; - -namespace UtopiaCanteenSystem.Api; - -/// -/// Self-hosted HTTP API on the central server PC for scanner clients (HttpListener, no shared SQLite over network). -/// -public static class CanteenLocalApiHost -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true - }; - - public static async Task RunAsync(RfidService rfidService, 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 local API listening: {string.Join(", ", prefixes)}"), "CanteenLocalApiHost"); - - 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, rfidService), cancellationToken); - } - } - finally - { - listener.Stop(); - listener.Close(); - } - } - - private static async Task ProcessRequestAsync(HttpListenerContext ctx, RfidService rfid) - { - 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"; - - if (method == "POST" && path.Equals("/api/rfid/scan", StringComparison.OrdinalIgnoreCase)) - { - await HandleScanAsync(ctx, rfid).ConfigureAwait(false); - return; - } - - if (method == "GET") - { - if (path.Equals("/api/rfid/scans/today", StringComparison.OrdinalIgnoreCase)) - { - await WriteJsonAsync(ctx, rfid.GetScansForToday().Select(ScanRecordDto.FromEntity).ToList()).ConfigureAwait(false); - return; - } - - if (path.Equals("/api/rfid/scans/last", StringComparison.OrdinalIgnoreCase)) - { - var last = rfid.GetLastScan(); - await WriteJsonAsync(ctx, last == null ? null : ScanRecordDto.FromEntity(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(ScanRecordDto.FromEntity).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 { 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 { 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 { 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 { count = n }).ConfigureAwait(false); - return; - } - } - - await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Log(ex, "CanteenLocalApiHost.ProcessRequestAsync"); - try - { - await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false); - } - catch - { - // Ignore - } - } - } - - private static async Task HandleScanAsync(HttpListenerContext ctx, RfidService rfid) - { - RfidScanRequestDto? 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(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 = RfidScanResponseDto.FromScanResult(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, 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 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; - } -} diff --git a/Services/CanteenBackendServices.cs b/Services/CanteenBackendServices.cs new file mode 100644 index 0000000..af7630b --- /dev/null +++ b/Services/CanteenBackendServices.cs @@ -0,0 +1,54 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Server-side backend dependencies: SQLite, RFID processing, HRMS cache sync, production post. +/// Only constructed when . +/// +public sealed class CanteenBackendServices +{ + private readonly SemaphoreSlim _cacheSyncLock = new(1, 1); + private readonly SemaphoreSlim _orderSyncLock = new(1, 1); + + public CanteenBackendServices( + RfidService rfid, + IOfflineCacheSyncService offlineCacheSync, + ISyncService productionSync, + IConfigService config) + { + Rfid = rfid; + OfflineCacheSync = offlineCacheSync; + ProductionSync = productionSync; + Config = config; + } + + public RfidService Rfid { get; } + public IOfflineCacheSyncService OfflineCacheSync { get; } + public ISyncService ProductionSync { get; } + public IConfigService Config { get; } + + public async Task RunCacheSyncExclusiveAsync(Func> action, CancellationToken cancellationToken = default) + { + await _cacheSyncLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await action().ConfigureAwait(false); + } + finally + { + _cacheSyncLock.Release(); + } + } + + public async Task RunOrderSyncExclusiveAsync(Func> action, CancellationToken cancellationToken = default) + { + await _orderSyncLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await action().ConfigureAwait(false); + } + finally + { + _orderSyncLock.Release(); + } + } +}