using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
///
/// 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;
public SyncService(IDbContextFactory dbFactory, IConfigService configService)
{
_dbFactory = dbFactory;
_configService = configService;
}
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
{
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())
{
toSync = await db.LunchOrderTransactions
.Where(r => !r.IsSynced)
.OrderBy(r => r.ScanTime)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
if (toSync.Count == 0)
return;
try
{
// Insert into MySQL using transaction for atomicity
await using var mysqlConn = new MySqlConnection(connectionString);
await mysqlConn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await mysqlConn.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
// 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 (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);
}
}