diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 6b9bbe3..1d2d490 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -122,7 +122,7 @@ public class AppDbContext : DbContext } /// - /// Lightweight schema upgrade: add SiteId and DeviceId to lunch_order_transactions if missing (no EF migrations). + /// Lightweight schema upgrade: add SiteId, DeviceId and IpAddress to lunch_order_transactions if missing (no EF migrations). /// Does not delete any data. /// private void UpgradeLunchOrderTransactionsSchemaIfNeeded() @@ -155,6 +155,13 @@ public class AppDbContext : DbContext cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN DeviceId TEXT DEFAULT ''"; cmd.ExecuteNonQuery(); } + + if (columns.Count > 0 && !columns.Contains("IpAddress", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN IpAddress TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } } catch { diff --git a/Models/ScanRecord.cs b/Models/ScanRecord.cs index decc340..f46e4d2 100644 --- a/Models/ScanRecord.cs +++ b/Models/ScanRecord.cs @@ -1,7 +1,7 @@ namespace UtopiaCanteenSystem.Models; /// -/// Represents a single RFID scan event. Used for local storage and UIND sync. +/// Represents a single RFID scan event. Used for local storage and sync. /// public class ScanRecord { @@ -10,10 +10,12 @@ public class ScanRecord public string CardId { get; set; } = string.Empty; /// UTC time when the scan occurred. public DateTime ScanTime { get; set; } - /// True after record has been successfully sent to the sync API. + /// True after record has been successfully sent / synced. public bool IsSynced { get; set; } - /// Site identifier where the scan occurred (from config). + /// Site identifier where the scan occurred (2-digit string, e.g. "02"). public string SiteId { get; set; } = string.Empty; /// Stable device identifier that generated the scan (from config). public string DeviceId { get; set; } = string.Empty; + /// IP address of the device at scan time. + public string IpAddress { get; set; } = string.Empty; } diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index 68fb7aa..5458681 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -51,11 +52,46 @@ public class ConfigService : IConfigService SaveConfig(); } - public string GetSiteId() => _config.SiteId ?? "SITE : 1"; + public string GetSiteId() + { + // Normalize to a 2-digit string (e.g. "02", "07", "12"). + // Accepts legacy values like "SITE : 2" or "2" and converts them. + var raw = _config.SiteId; + if (string.IsNullOrWhiteSpace(raw)) + return "01"; + + var digits = new string(raw.Where(char.IsDigit).ToArray()); + if (string.IsNullOrWhiteSpace(digits)) + return "01"; + + if (!int.TryParse(digits, out var numeric)) + return "01"; + + return Math.Clamp(numeric, 0, 99).ToString("D2"); + } public void SetSiteId(string siteId) { - _config.SiteId = string.IsNullOrWhiteSpace(siteId) ? "SITE : 1" : (siteId ?? string.Empty); + // Store the normalized 2-digit site code. + var normalized = siteId; + if (string.IsNullOrWhiteSpace(normalized)) + { + normalized = "01"; + } + else + { + var digits = new string(normalized.Where(char.IsDigit).ToArray()); + if (string.IsNullOrWhiteSpace(digits) || !int.TryParse(digits, out var numeric)) + { + normalized = "01"; + } + else + { + normalized = Math.Clamp(numeric, 0, 99).ToString("D2"); + } + } + + _config.SiteId = normalized; SaveConfig(); } @@ -138,6 +174,14 @@ public class ConfigService : IConfigService SaveConfig(); } + public string GetMySqlConnectionString() => _config.MySqlConnectionString ?? string.Empty; + + public void SetMySqlConnectionString(string connectionString) + { + _config.MySqlConnectionString = connectionString ?? string.Empty; + SaveConfig(); + } + private AppConfig LoadConfig() { try @@ -193,5 +237,8 @@ public class ConfigService : IConfigService public bool RememberAdminCredentials { get; set; } = false; public string SavedAdminUsername { get; set; } = string.Empty; public string SavedAdminPasswordProtected { get; set; } = string.Empty; + + // MySQL connection string for direct DB sync (local testing). + public string MySqlConnectionString { get; set; } = string.Empty; } } diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs index c7854c3..b1f98ee 100644 --- a/Services/IConfigService.cs +++ b/Services/IConfigService.cs @@ -25,4 +25,7 @@ public interface IConfigService void SetSavedAdminUsername(string username); string GetSavedAdminPassword(); void SetSavedAdminPassword(string password); + + string GetMySqlConnectionString(); + void SetMySqlConnectionString(string connectionString); } diff --git a/Services/RfidService.cs b/Services/RfidService.cs index 12bbf0f..3be1fe3 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using System.Net; +using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using UtopiaCanteenSystem.Data; @@ -81,7 +83,8 @@ public class RfidService : IRfidService ScanTime = nowUtc, IsSynced = false, SiteId = _configService.GetSiteId(), - DeviceId = _configService.GetDeviceId() + DeviceId = _configService.GetDeviceId(), + IpAddress = GetLocalIpAddress() }; db.LunchOrderTransactions.Add(record); db.SaveChanges(); @@ -214,4 +217,18 @@ public class RfidService : IRfidService var remaining = (int)Math.Ceiling((endUtc - nowUtc).TotalSeconds); return Math.Max(0, remaining); } + + private static string GetLocalIpAddress() + { + try + { + var host = Dns.GetHostEntry(Dns.GetHostName()); + var ip = host.AddressList.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork); + return ip?.ToString() ?? string.Empty; + } + catch + { + return string.Empty; + } + } } diff --git a/Services/SyncService.cs b/Services/SyncService.cs index 0e34011..5c398dd 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -1,21 +1,18 @@ -using System.Net.Http.Json; -using System.Text.Json; using Microsoft.EntityFrameworkCore; +using MySqlConnector; using UtopiaCanteenSystem.Data; using UtopiaCanteenSystem.Models; -using System.Net.Http; namespace UtopiaCanteenSystem.Services; /// -/// Scheduled sync: fetches unsynced lunch_order_transactions, POSTs them to the configured API, -/// and deletes uploaded records from local SQLite on success. +/// Scheduled sync: fetches unsynced lunch_order_transactions, inserts them into MySQL database, +/// marks them as synced in SQLite, and periodically cleans up old synced rows. /// public class SyncService : ISyncService { private readonly IDbContextFactory _dbFactory; private readonly IConfigService _configService; - private static readonly HttpClient HttpClient = new(); public SyncService(IDbContextFactory dbFactory, IConfigService configService) { @@ -25,9 +22,10 @@ public class SyncService : ISyncService public async Task SyncNowAsync(CancellationToken cancellationToken = default) { - var endpoint = _configService.GetSyncApiEndpoint(); - if (string.IsNullOrWhiteSpace(endpoint)) - return; + var connectionString = _configService.GetMySqlConnectionString(); + // TODO: For local testing only. Set MySqlConnectionString in appsettings.json or via config to avoid hardcoding. + if (string.IsNullOrWhiteSpace(connectionString)) + connectionString = "Server=localhost;Database=canteen_prod;User=root;Password=Root@12345_;Port=3306;"; List toSync; using (var db = _dbFactory.CreateDbContext()) @@ -42,39 +40,108 @@ public class SyncService : ISyncService if (toSync.Count == 0) return; - var payload = toSync.Select(r => new - { - DeviceLocalRowId = r.Id, - ScanTimeUtc = r.ScanTime, - SiteId = r.SiteId ?? string.Empty, - DeviceId = r.DeviceId ?? string.Empty, - r.CardId - }).ToList(); - try { - var response = await HttpClient - .PostAsJsonAsync(endpoint, payload, cancellationToken: cancellationToken) - .ConfigureAwait(false); + // Insert into MySQL using transaction for atomicity + await using var mysqlConn = new MySqlConnection(connectionString); + await mysqlConn.OpenAsync(cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - return; + await using var transaction = await mysqlConn.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - var ids = toSync.Select(r => r.Id).ToList(); - using (var db = _dbFactory.CreateDbContext()) + try { - var records = await db.LunchOrderTransactions - .Where(r => ids.Contains(r.Id)) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - // On successful upload, delete uploaded scan events from local SQLite. - db.LunchOrderTransactions.RemoveRange(records); - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Use INSERT IGNORE to handle duplicates (unique key on DeviceId, DeviceLocalRowId) + // This ensures no duplicates even if sync runs multiple times + var insertSql = @" + INSERT IGNORE INTO lunch_order_transactions + (DeviceLocalRowId, ScanTimeUtc, SiteId, DeviceId, CardId, IpAddress, ReceivedAtUtc) + VALUES + (@DeviceLocalRowId, @ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))"; + + await using var cmd = new MySqlCommand(insertSql, mysqlConn, transaction); + + foreach (var record in toSync) + { + cmd.Parameters.Clear(); + cmd.Parameters.AddWithValue("@DeviceLocalRowId", record.Id); + cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime); + cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty); + cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty); + cmd.Parameters.AddWithValue("@CardId", record.CardId ?? string.Empty); + cmd.Parameters.AddWithValue("@IpAddress", record.IpAddress ?? string.Empty); + + var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + // Note: INSERT IGNORE returns 0 if row already exists (duplicate), 1 if inserted + } + + // Commit MySQL transaction + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + // Only mark as synced in SQLite after successful MySQL insert + var ids = toSync.Select(r => r.Id).ToList(); + using (var db = _dbFactory.CreateDbContext()) + { + var records = await db.LunchOrderTransactions + .Where(r => ids.Contains(r.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + foreach (var record in records) + record.IsSynced = true; + + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + // Day-end cleanup: delete only synced rows from previous days. + await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Rollback MySQL transaction on error + try + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + // Ignore rollback errors + } + + // Log error for debugging (you can replace with proper logging) + System.Diagnostics.Debug.WriteLine($"Sync error: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}"); + + throw; // Re-throw to be caught by outer catch } } - catch + catch (Exception ex) { + // Log error for debugging (you can replace with proper logging) + System.Diagnostics.Debug.WriteLine($"Sync failed: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}"); + // Leave records intact; will retry on next run } } + + /// + /// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day). + /// + private async Task CleanupOldSyncedRowsAsync(CancellationToken cancellationToken) + { + // Define "today" by the local calendar day, but ScanTime is stored as UTC. + var startOfTodayLocal = DateTime.Today; + var startUtc = startOfTodayLocal.ToUniversalTime(); + + using var db = _dbFactory.CreateDbContext(); + var oldSynced = await db.LunchOrderTransactions + .Where(r => r.IsSynced && r.ScanTime < startUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (oldSynced.Count == 0) + return; + + db.LunchOrderTransactions.RemoveRange(oldSynced); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } } diff --git a/UtopiaCanteenSystem.csproj b/UtopiaCanteenSystem.csproj index 28485b0..47ec17f 100644 --- a/UtopiaCanteenSystem.csproj +++ b/UtopiaCanteenSystem.csproj @@ -20,6 +20,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive +