diff --git a/Services/CanteenBackendApiClient.cs b/Services/CanteenBackendApiClient.cs new file mode 100644 index 0000000..b762920 --- /dev/null +++ b/Services/CanteenBackendApiClient.cs @@ -0,0 +1,336 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using UtopiaCanteen.Shared; +using UtopiaCanteenSystem.Api; +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Frontend HTTP client: all scan/history/stats and settings actions go through the central backend API. +/// +public class CanteenBackendApiClient : ICanteenBackendApiClient +{ + private readonly HttpClient _http; + private readonly IConfigService _config; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public CanteenBackendApiClient(HttpClient http, IConfigService config) + { + _http = http; + _config = config; + } + + private string BaseUrl => (_config.GetBackendBaseUrl() ?? string.Empty).TrimEnd('/'); + + public async Task HealthCheckAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + return false; + try + { + var url = $"{BaseUrl}/api/health"; + var resp = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false); + return resp != null && string.Equals(resp.Status, "ok", StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) + { + Logger.Log(ex, "CanteenBackendApiClient.HealthCheckAsync"); + return false; + } + } + + public async Task SyncCacheNowAsync(CancellationToken cancellationToken = default) => + await PostJobAsync("/api/cache/sync-now", cancellationToken).ConfigureAwait(false); + + public async Task SyncOrdersNowAsync(CancellationToken cancellationToken = default) => + await PostJobAsync("/api/orders/sync-now", cancellationToken).ConfigureAwait(false); + + public async Task GetCacheStatusAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + return null; + try + { + var url = $"{BaseUrl}/api/cache/status"; + return await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log(ex, "CanteenBackendApiClient.GetCacheStatusAsync"); + return null; + } + } + + public (bool Success, string Message) ProcessScan(string cardId) + { + var r = ProcessScanDetailed(cardId); + return (r.Success, r.Message); + } + + public ScanResult ProcessScanDetailed(string cardId) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + return new ScanResult(false, "Backend URL is not configured.", 0); + + try + { + var url = $"{BaseUrl}/api/rfid/scan"; + var body = new ScanRequest + { + CardId = cardId?.Trim() ?? string.Empty, + DeviceId = _config.GetDeviceId(), + SiteId = _config.GetSiteId(), + IpAddress = null + }; + + using var response = _http.PostAsJsonAsync(url, body, JsonOptions).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + var err = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return new ScanResult(false, + $"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}", 0); + } + + var dto = response.Content.ReadFromJsonAsync(JsonOptions).GetAwaiter().GetResult(); + if (dto == null) + return new ScanResult(false, "Invalid response from backend.", 0); + + return new ScanResult( + dto.Success, + dto.Message ?? string.Empty, + dto.CooldownSecondsRemaining, + dto.Employee == null ? null : ApiDtoMapper.ToModel(dto.Employee), + (MealSession)dto.MealSession, + dto.EmployeeSiteId, + dto.CurrentSiteId); + } + catch (Exception ex) + { + Logger.Log(ex, "CanteenBackendApiClient.ProcessScanDetailed"); + return new ScanResult(false, "Cannot reach backend: " + ex.Message, 0); + } + } + + public ScanRecord? GetLastScan() => + GetAsync("/api/rfid/scans/last", d => d == null ? null : ApiDtoMapper.ToEntity(d)); + + public IReadOnlyList GetLastScans(int count) + { + if (count <= 0) return Array.Empty(); + return GetAsync, IReadOnlyList>( + $"/api/rfid/scans/recent?count={count}", + list => list?.Select(ApiDtoMapper.ToEntity).ToList() ?? new List()); + } + + public IReadOnlyList GetScansForToday() => + GetAsync, IReadOnlyList>( + "/api/rfid/scans/today", + list => list?.Select(ApiDtoMapper.ToEntity).ToList() ?? new List()); + + public int GetTodayScanCount() => GetTodayScanCountAsync().GetAwaiter().GetResult(); + + public async Task GetTodayScanCountAsync(CancellationToken cancellationToken = default) => + await GetCountAsync("/api/rfid/stats/today", cancellationToken).ConfigureAwait(false); + + public async Task GetTotalScanCountAsync(CancellationToken cancellationToken = default) => + await GetCountAsync("/api/rfid/stats/total", cancellationToken).ConfigureAwait(false); + + public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(cardId)) return 0; + return await GetCountAsync($"/api/rfid/stats/total-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}", cancellationToken) + .ConfigureAwait(false); + } + + public async Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(cardId)) return 0; + return await GetCountAsync($"/api/rfid/stats/today-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}", cancellationToken) + .ConfigureAwait(false); + } + + public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice) + { + // Backend owns SQLite; no local update on frontend. + } + + private async Task PostJobAsync(string path, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + { + return new ManualSyncResponse + { + Success = false, + Message = "Backend URL is not configured." + }; + } + + try + { + var url = $"{BaseUrl}{path}"; + using var response = await _http.PostAsync(url, null, cancellationToken).ConfigureAwait(false); + var dto = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + .ConfigureAwait(false); + if (dto != null) + return dto; + + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return new ManualSyncResponse + { + Success = false, + Message = $"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}" + }; + } + + return new ManualSyncResponse { Success = false, Message = "Invalid response from backend." }; + } + catch (Exception ex) + { + Logger.Log(ex, $"CanteenBackendApiClient.PostJobAsync{path}"); + return new ManualSyncResponse + { + Success = false, + Message = "Cannot reach backend: " + ex.Message + }; + } + } + + private async Task GetCountAsync(string path, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) return 0; + try + { + var url = $"{BaseUrl}{path}"; + var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false); + return wrap?.Count ?? 0; + } + catch (Exception ex) + { + Logger.Log(ex, $"CanteenBackendApiClient.GetCountAsync{path}"); + return 0; + } + } + + private TResult GetAsync(string path, Func map) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + return map(default); + try + { + var url = $"{BaseUrl}{path}"; + var data = _http.GetFromJsonAsync(url, JsonOptions).GetAwaiter().GetResult(); + return map(data); + } + catch (Exception ex) + { + Logger.Log(ex, $"CanteenBackendApiClient.GetAsync{path}"); + return map(default); + } + } + + public async Task GetHealthAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + return null; + try + { + return await _http.GetFromJsonAsync($"{BaseUrl}/api/health", JsonOptions, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log(ex, "CanteenBackendApiClient.GetHealthAsync"); + return null; + } + } + + public async Task> GetMealSchedulesAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + return Array.Empty(); + try + { + var list = await _http.GetFromJsonAsync>($"{BaseUrl}/api/meal-schedules", JsonOptions, cancellationToken) + .ConfigureAwait(false); + return list ?? new List(); + } + catch (Exception ex) + { + Logger.Log(ex, "CanteenBackendApiClient.GetMealSchedulesAsync"); + return Array.Empty(); + } + } + + public async Task CreateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default) + { + EnsureBaseUrl(); + var url = $"{BaseUrl}/api/meal-schedules"; + using var response = await _http.PostAsJsonAsync(url, schedule, JsonOptions, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false); + var created = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); + return created?.Id ?? 0; + } + + public async Task UpdateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default) + { + EnsureBaseUrl(); + var url = $"{BaseUrl}/api/meal-schedules/{schedule.Id}"; + using var response = await _http.PutAsJsonAsync(url, schedule, JsonOptions, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteMealScheduleAsync(long id, CancellationToken cancellationToken = default) + { + EnsureBaseUrl(); + var url = $"{BaseUrl}/api/meal-schedules/{id}"; + using var response = await _http.DeleteAsync(url, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false); + } + + public async Task GetEmployeeLocationSiteAsync(string employeeId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(BaseUrl) || string.IsNullOrWhiteSpace(employeeId)) + return null; + try + { + var url = $"{BaseUrl}/api/employees/location-site?employeeId={Uri.EscapeDataString(employeeId.Trim())}"; + var resp = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken) + .ConfigureAwait(false); + return string.IsNullOrWhiteSpace(resp?.LocationSiteId) ? null : resp.LocationSiteId.Trim(); + } + catch (Exception ex) + { + Logger.Log(ex, "CanteenBackendApiClient.GetEmployeeLocationSiteAsync"); + return null; + } + } + + private void EnsureBaseUrl() + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + throw new InvalidOperationException("Backend URL is not configured."); + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + var err = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new HttpRequestException($"Backend error ({(int)response.StatusCode}): {Truncate(err, 300)}"); + } + + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s) || s.Length <= max) return s; + return s.Substring(0, max) + "…"; + } +} diff --git a/Services/ClientRfidService.cs b/Services/ClientRfidService.cs deleted file mode 100644 index cebd462..0000000 --- a/Services/ClientRfidService.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using UtopiaCanteenSystem.Api; -using UtopiaCanteenSystem.Models; - -namespace UtopiaCanteenSystem.Services; - -/// -/// Scanner PC: forwards scans and dashboard queries to the central server HTTP API (no local transaction DB). -/// -public class ClientRfidService : IRfidService -{ - private readonly HttpClient _http; - private readonly IConfigService _config; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true - }; - - public ClientRfidService(HttpClient http, IConfigService config) - { - _http = http; - _config = config; - } - - private string BaseUrl => (_config.GetCentralServerBaseUrl() ?? string.Empty).TrimEnd('/'); - - public (bool Success, string Message) ProcessScan(string cardId) - { - var r = ProcessScanDetailed(cardId); - return (r.Success, r.Message); - } - - public ScanResult ProcessScanDetailed(string cardId) - { - if (string.IsNullOrWhiteSpace(BaseUrl)) - return new ScanResult(false, "Central server URL is not configured.", 0); - - try - { - var url = $"{BaseUrl}/api/rfid/scan"; - var body = new RfidScanRequestDto - { - CardId = cardId?.Trim() ?? string.Empty, - DeviceId = _config.GetDeviceId(), - SiteId = _config.GetSiteId(), - IpAddress = null - }; - - using var response = _http.PostAsJsonAsync(url, body, JsonOptions).GetAwaiter().GetResult(); - if (!response.IsSuccessStatusCode) - { - var err = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - return new ScanResult(false, - $"Central server error ({(int)response.StatusCode}): {Truncate(err, 200)}", 0); - } - - var dto = response.Content.ReadFromJsonAsync(JsonOptions).GetAwaiter().GetResult(); - if (dto == null) - return new ScanResult(false, "Invalid response from central server.", 0); - - return new ScanResult( - dto.Success, - dto.Message ?? string.Empty, - dto.CooldownSecondsRemaining, - dto.Employee?.ToModel(), - (MealSession)dto.MealSession, - dto.EmployeeSiteId, - dto.CurrentSiteId); - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.ProcessScanDetailed"); - return new ScanResult(false, "Cannot reach central server: " + ex.Message, 0); - } - } - - public ScanRecord? GetLastScan() - { - if (string.IsNullOrWhiteSpace(BaseUrl)) - return null; - try - { - var url = $"{BaseUrl}/api/rfid/scans/last"; - var dto = _http.GetFromJsonAsync(url, JsonOptions).GetAwaiter().GetResult(); - return dto?.ToEntity(); - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.GetLastScan"); - return null; - } - } - - public IReadOnlyList GetLastScans(int count) - { - if (string.IsNullOrWhiteSpace(BaseUrl) || count <= 0) - return Array.Empty(); - try - { - var url = $"{BaseUrl}/api/rfid/scans/recent?count={count}"; - var list = _http.GetFromJsonAsync>(url, JsonOptions).GetAwaiter().GetResult(); - return list?.Select(d => d.ToEntity()).ToList() ?? new List(); - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.GetLastScans"); - return Array.Empty(); - } - } - - public IReadOnlyList GetScansForToday() - { - if (string.IsNullOrWhiteSpace(BaseUrl)) - return Array.Empty(); - try - { - var url = $"{BaseUrl}/api/rfid/scans/today"; - var list = _http.GetFromJsonAsync>(url, JsonOptions).GetAwaiter().GetResult(); - return list?.Select(d => d.ToEntity()).ToList() ?? new List(); - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.GetScansForToday"); - return Array.Empty(); - } - } - - public int GetTodayScanCount() => GetTodayScanCountAsync().GetAwaiter().GetResult(); - - public async Task GetTodayScanCountAsync(CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(BaseUrl)) - return 0; - try - { - var url = $"{BaseUrl}/api/rfid/stats/today"; - var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false); - return wrap?.Count ?? 0; - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.GetTodayScanCountAsync"); - return 0; - } - } - - public async Task GetTotalScanCountAsync(CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(BaseUrl)) - return 0; - try - { - var url = $"{BaseUrl}/api/rfid/stats/total"; - var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false); - return wrap?.Count ?? 0; - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.GetTotalScanCountAsync"); - return 0; - } - } - - public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(BaseUrl) || string.IsNullOrWhiteSpace(cardId)) - return 0; - try - { - var url = $"{BaseUrl}/api/rfid/stats/total-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}"; - var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false); - return wrap?.Count ?? 0; - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.GetTotalScanCountForCardAsync"); - return 0; - } - } - - public async Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(BaseUrl) || string.IsNullOrWhiteSpace(cardId)) - return 0; - try - { - var url = $"{BaseUrl}/api/rfid/stats/today-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}"; - var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false); - return wrap?.Count ?? 0; - } - catch (Exception ex) - { - Logger.Log(ex, "ClientRfidService.GetTodayScanCountForCardAsync"); - return 0; - } - } - - public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice) - { - // Server owns SQLite; client has nothing to update locally. - } - - private static string Truncate(string s, int max) - { - if (string.IsNullOrEmpty(s) || s.Length <= max) - return s; - return s.Substring(0, max) + "…"; - } - - private sealed class CountDto - { - public int Count { get; set; } - } -} diff --git a/Services/ICanteenBackendApiClient.cs b/Services/ICanteenBackendApiClient.cs new file mode 100644 index 0000000..78eac6c --- /dev/null +++ b/Services/ICanteenBackendApiClient.cs @@ -0,0 +1,29 @@ +using UtopiaCanteen.Shared; + +namespace UtopiaCanteenSystem.Services; + +/// +/// HTTP client for the central canteen backend API (scanner UI and settings). +/// +public interface ICanteenBackendApiClient : IRfidService +{ + Task HealthCheckAsync(CancellationToken cancellationToken = default); + + Task SyncCacheNowAsync(CancellationToken cancellationToken = default); + + Task SyncOrdersNowAsync(CancellationToken cancellationToken = default); + + Task GetCacheStatusAsync(CancellationToken cancellationToken = default); + + Task GetHealthAsync(CancellationToken cancellationToken = default); + + Task> GetMealSchedulesAsync(CancellationToken cancellationToken = default); + + Task CreateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default); + + Task UpdateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default); + + Task DeleteMealScheduleAsync(long id, CancellationToken cancellationToken = default); + + Task GetEmployeeLocationSiteAsync(string employeeId, CancellationToken cancellationToken = default); +}