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();
+ }
+ }
+}