Utopia-Canteen-System/Data/AppDbContext.cs

286 lines
10 KiB
C#

using System.Data;
using System.IO;
using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Data;
/// <summary>
/// SQLite DbContext for Labour and lunch_order_transactions (scan records) tables.
/// Database file is stored under LocalApplicationData so it persists across app updates
/// when deployed from a file server.
/// </summary>
public class AppDbContext : DbContext
{
private static string DbPath => DatabasePath.GetDbPath();
public DbSet<Labour> Labour { get; set; }
public DbSet<ScanRecord> LunchOrderTransactions { get; set; }
public DbSet<AdminLoginRecord> AdminLoginRecords { get; set; }
public AppDbContext() { }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
optionsBuilder.UseSqlite($"Data Source={DbPath}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Labour: CardId should be indexed for lookups.
modelBuilder.Entity<Labour>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.CardId);
});
// lunch_order_transactions (scan records): Index for unsynced queries and by ScanTime.
modelBuilder.Entity<ScanRecord>(e =>
{
e.ToTable("lunch_order_transactions");
e.HasKey(x => x.Id);
e.HasIndex(x => x.IsSynced);
e.HasIndex(x => x.ScanTime);
});
modelBuilder.Entity<AdminLoginRecord>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.LoginTimeUtc);
});
}
/// <summary>
/// Ensures database exists and is migrated. Call on app startup.
/// </summary>
public void EnsureDatabaseCreated()
{
Database.EnsureCreated();
MigrateScanRecordsToLunchOrderTransactionsIfNeeded();
UpgradeLunchOrderTransactionsSchemaIfNeeded();
EnsureAdminLoginTableExists();
RemoveLegacyMealRelatedSchemaIfNeeded();
}
/// <summary>
/// One-time migration: rename old tables (ScanRecords or CanteenScanEvents) to lunch_order_transactions for existing databases.
/// </summary>
private void MigrateScanRecordsToLunchOrderTransactionsIfNeeded()
{
try
{
var conn = Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var hasOldScanRecords = false;
var hasOldCanteenScanEvents = false;
var hasNew = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='ScanRecords'";
using var r = cmd.ExecuteReader();
hasOldScanRecords = r.Read();
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='CanteenScanEvents'";
using var r = cmd.ExecuteReader();
hasOldCanteenScanEvents = r.Read();
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='lunch_order_transactions'";
using var r = cmd.ExecuteReader();
hasNew = r.Read();
}
if (!hasNew)
{
if (hasOldScanRecords)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "ALTER TABLE ScanRecords RENAME TO lunch_order_transactions";
cmd.ExecuteNonQuery();
}
else if (hasOldCanteenScanEvents)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "ALTER TABLE CanteenScanEvents RENAME TO lunch_order_transactions";
cmd.ExecuteNonQuery();
}
}
}
catch
{
// Ignore; DB may be new or already migrated
}
}
/// <summary>
/// Lightweight schema upgrade: add SiteId, DeviceId and IpAddress to lunch_order_transactions if missing (no EF migrations).
/// Does not delete any data.
/// </summary>
private void UpgradeLunchOrderTransactionsSchemaIfNeeded()
{
try
{
var conn = Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var columns = new List<string>();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT name FROM pragma_table_info('lunch_order_transactions')";
using var r = cmd.ExecuteReader();
while (r.Read())
columns.Add(r.GetString(0));
}
if (columns.Count > 0 && !columns.Contains("SiteId", StringComparer.OrdinalIgnoreCase))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN SiteId TEXT DEFAULT ''";
cmd.ExecuteNonQuery();
}
if (columns.Count > 0 && !columns.Contains("DeviceId", StringComparer.OrdinalIgnoreCase))
{
using var cmd = conn.CreateCommand();
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
{
// Ignore; existing DB may already have columns or be incompatible
}
}
/// <summary>
/// Lightweight creation of AdminLoginRecords table for existing databases.
/// </summary>
private void EnsureAdminLoginTableExists()
{
try
{
var conn = Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"CREATE TABLE IF NOT EXISTS AdminLoginRecords (" +
"Id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"Username TEXT NOT NULL, " +
"EmployeeId TEXT NOT NULL, " +
"LoginTimeUtc TEXT NOT NULL)";
cmd.ExecuteNonQuery();
}
catch
{
// Ignore; table may already exist or DB may be read-only.
}
}
/// <summary>
/// Removes legacy meal-related tables and columns that should never be present in production.
/// This is a best-effort cleanup that runs on every startup for existing SQLite databases.
/// </summary>
private void RemoveLegacyMealRelatedSchemaIfNeeded()
{
try
{
var conn = Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
// 1) Drop whole legacy tables if they exist.
var legacyTables = new[]
{
"Employees",
"MealRates",
// Support both spellings of the temporary meal plan overrides table.
"TemporaryMealPlanOverirdes",
"TemporaryMealPlanOverrides"
};
foreach (var table in legacyTables)
{
using var dropCmd = conn.CreateCommand();
dropCmd.CommandText = $"DROP TABLE IF EXISTS \"{table}\"";
dropCmd.ExecuteNonQuery();
}
// 2) Strip specific columns from any user tables that might contain them.
// We don't assume which table they belong to; instead we scan all non-system tables.
var legacyColumns = new[]
{
"MealSession",
"MealPlanType",
"AppliedRate",
"TotalAmount"
};
var tableNames = new List<string>();
using (var tablesCmd = conn.CreateCommand())
{
tablesCmd.CommandText =
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'";
using var reader = tablesCmd.ExecuteReader();
while (reader.Read())
tableNames.Add(reader.GetString(0));
}
foreach (var tableName in tableNames)
{
var existingColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var columnsCmd = conn.CreateCommand())
{
// Use pragma_table_info to discover columns for the current table.
var safeTableName = tableName.Replace("'", "''");
columnsCmd.CommandText = $"SELECT name FROM pragma_table_info('{safeTableName}')";
using var reader = columnsCmd.ExecuteReader();
while (reader.Read())
existingColumns.Add(reader.GetString(0));
}
foreach (var legacyColumn in legacyColumns)
{
if (!existingColumns.Contains(legacyColumn))
continue;
try
{
using var dropColumnCmd = conn.CreateCommand();
dropColumnCmd.CommandText =
$"ALTER TABLE \"{tableName}\" DROP COLUMN \"{legacyColumn}\"";
dropColumnCmd.ExecuteNonQuery();
}
catch
{
// Older SQLite versions may not support DROP COLUMN; ignore in that case.
// If needed in the future, a more invasive recreate-table migration can be added.
}
}
}
}
catch
{
// Best-effort only: if anything fails, do not block app startup.
}
}
}