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
+