feat(settings): add manual sync + secure config template
Add Settings PostDataNow action via ISyncService Remove hardcoded MySQL fallback; skip sync when not configured Use production snake_case columns + always run cleanup Add appsettings.example.json; ignore appsettings.json to avoid secrets Rename Settings Cancel button to Backpull/1/head
parent
35161336d0
commit
25a808907d
|
|
@ -15,6 +15,9 @@ obj/
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Local config (contains secrets)
|
||||||
|
appsettings.json
|
||||||
|
|
||||||
# SQLite database files (local runtime data)
|
# SQLite database files (local runtime data)
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ public partial class App : Application
|
||||||
() => new ScannerDashboardViewModel(rfidService, navigationService, session, configService),
|
() => new ScannerDashboardViewModel(rfidService, navigationService, session, configService),
|
||||||
() => new MainDashboardViewModel(navigationService, rfidService, configService, session),
|
() => new MainDashboardViewModel(navigationService, rfidService, configService, session),
|
||||||
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService),
|
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService),
|
||||||
() => new SettingsViewModel(configService, navigationService, adminAuditService));
|
() => new SettingsViewModel(configService, navigationService, adminAuditService, syncService));
|
||||||
|
|
||||||
var mainViewModel = new MainViewModel(navigationService);
|
var mainViewModel = new MainViewModel(navigationService);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,11 @@ public class ConfigService : IConfigService
|
||||||
SaveConfig();
|
SaveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetMySqlConnectionString() => _config.MySqlConnectionString ?? string.Empty;
|
/// <summary>
|
||||||
|
/// Returns MySQL connection string for sync. Set via appsettings.json (copy from appsettings.example.json).
|
||||||
|
/// No default credentials; returns empty if not configured.
|
||||||
|
/// </summary>
|
||||||
|
public string GetMySqlConnectionString() => (_config.MySqlConnectionString ?? string.Empty).Trim();
|
||||||
|
|
||||||
public void SetMySqlConnectionString(string connectionString)
|
public void SetMySqlConnectionString(string connectionString)
|
||||||
{
|
{
|
||||||
|
|
@ -238,7 +242,7 @@ public class ConfigService : IConfigService
|
||||||
public string SavedAdminUsername { get; set; } = string.Empty;
|
public string SavedAdminUsername { get; set; } = string.Empty;
|
||||||
public string SavedAdminPasswordProtected { get; set; } = string.Empty;
|
public string SavedAdminPasswordProtected { get; set; } = string.Empty;
|
||||||
|
|
||||||
// MySQL connection string for direct DB sync (local testing).
|
// MySQL connection string for sync. Set in appsettings.json (see appsettings.example.json). No default.
|
||||||
public string MySqlConnectionString { get; set; } = string.Empty;
|
public string MySqlConnectionString { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ public class SyncService : ISyncService
|
||||||
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
|
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var connectionString = _configService.GetMySqlConnectionString();
|
var connectionString = _configService.GetMySqlConnectionString();
|
||||||
// TODO: For local testing only. Set MySqlConnectionString in appsettings.json or via config to avoid hardcoding.
|
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
connectionString = "Server=localhost;Database=canteen_prod;User=root;Password=Root@12345_;Port=3306;";
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine("MySQL connection string not configured; skipping sync.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
List<ScanRecord> toSync;
|
List<ScanRecord> toSync;
|
||||||
using (var db = _dbFactory.CreateDbContext())
|
using (var db = _dbFactory.CreateDbContext())
|
||||||
|
|
@ -37,6 +39,9 @@ public class SyncService : ISyncService
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always run day-end cleanup (remove synced rows from previous days), even when there's nothing to sync.
|
||||||
|
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (toSync.Count == 0)
|
if (toSync.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -52,9 +57,10 @@ public class SyncService : ISyncService
|
||||||
{
|
{
|
||||||
// Use INSERT IGNORE to handle duplicates (unique key on DeviceId, DeviceLocalRowId)
|
// Use INSERT IGNORE to handle duplicates (unique key on DeviceId, DeviceLocalRowId)
|
||||||
// This ensures no duplicates even if sync runs multiple times
|
// This ensures no duplicates even if sync runs multiple times
|
||||||
|
// Column names match production: hrms.lunch_order_transactions (snake_case)
|
||||||
var insertSql = @"
|
var insertSql = @"
|
||||||
INSERT IGNORE INTO lunch_order_transactions
|
INSERT IGNORE INTO lunch_order_transactions
|
||||||
(DeviceLocalRowId, ScanTimeUtc, SiteId, DeviceId, CardId, IpAddress, ReceivedAtUtc)
|
(device_local_row_id, scan_time_utc, site_id, device_id, card_id, ip_address, received_at_utc)
|
||||||
VALUES
|
VALUES
|
||||||
(@DeviceLocalRowId, @ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
|
(@DeviceLocalRowId, @ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
private readonly INavigationService _navigation;
|
private readonly INavigationService _navigation;
|
||||||
private readonly IAdminAuditService _adminAudit;
|
private readonly IAdminAuditService _adminAudit;
|
||||||
|
private readonly ISyncService _syncService;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _syncApiEndpoint = string.Empty;
|
private string _syncApiEndpoint = string.Empty;
|
||||||
|
|
@ -44,11 +45,16 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
OnPropertyChanged(nameof(CanPostNow));
|
OnPropertyChanged(nameof(CanPostNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
public SettingsViewModel(IConfigService configService, INavigationService navigation, IAdminAuditService adminAudit)
|
public SettingsViewModel(
|
||||||
|
IConfigService configService,
|
||||||
|
INavigationService navigation,
|
||||||
|
IAdminAuditService adminAudit,
|
||||||
|
ISyncService syncService)
|
||||||
{
|
{
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
_navigation = navigation;
|
_navigation = navigation;
|
||||||
_adminAudit = adminAudit;
|
_adminAudit = adminAudit;
|
||||||
|
_syncService = syncService;
|
||||||
LoadFromConfig();
|
LoadFromConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +98,41 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
_navigation.NavigateBackFromSettings(GetDashboardTimeout());
|
_navigation.NavigateBackFromSettings(GetDashboardTimeout());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PostDataNow()
|
||||||
|
{
|
||||||
|
if (IsPosting)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SaveMessage = string.Empty;
|
||||||
|
IsError = false;
|
||||||
|
IsPosting = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// If not configured, do not attempt sync.
|
||||||
|
if (string.IsNullOrWhiteSpace(_configService.GetMySqlConnectionString()))
|
||||||
|
{
|
||||||
|
SaveMessage = "MySQL connection string not configured.";
|
||||||
|
IsError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _syncService.SyncNowAsync().ConfigureAwait(false);
|
||||||
|
SaveMessage = "Posted data to production.";
|
||||||
|
IsError = false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
SaveMessage = "Failed to post data to production.";
|
||||||
|
IsError = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsPosting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private TimeSpan GetDashboardTimeout()
|
private TimeSpan GetDashboardTimeout()
|
||||||
{
|
{
|
||||||
var seconds = _configService.GetScanTimeoutSeconds();
|
var seconds = _configService.GetScanTimeoutSeconds();
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
Foreground="{StaticResource MutedText}"
|
Foreground="{StaticResource MutedText}"
|
||||||
Margin="0,0,0,20" />-->
|
Margin="0,0,0,20" />-->
|
||||||
<TextBlock Text="Sync API Endpoint (UIND)"
|
<TextBlock Text="Sync Data To (UIND)"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
Foreground="{StaticResource PrimaryText}"
|
Foreground="{StaticResource PrimaryText}"
|
||||||
FontWeight="SemiBold" />
|
FontWeight="SemiBold" />
|
||||||
|
|
@ -263,9 +263,12 @@
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<TextBox Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
Text="{Binding SyncApiEndpoint, UpdateSourceTrigger=PropertyChanged}"
|
Text="Post data to production"
|
||||||
Style="{StaticResource ModernTextBoxStyle}" />
|
FontSize="18"
|
||||||
|
Foreground="{StaticResource PrimaryText}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="4,0,0,0" />
|
||||||
|
|
||||||
<!-- Post button right next to textbox -->
|
<!-- Post button right next to textbox -->
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
|
|
@ -276,10 +279,10 @@
|
||||||
IsEnabled="{Binding CanPostNow}" />
|
IsEnabled="{Binding CanPostNow}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Text="Endpoint used to sync scan records."
|
<TextBlock Text=""
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
Foreground="{StaticResource MutedText}"
|
Foreground="{StaticResource MutedText}"
|
||||||
Margin="0,0,0,20" />
|
Margin="0,0,0,20" />
|
||||||
|
|
||||||
|
|
||||||
<!-- Message area -->
|
<!-- Message area -->
|
||||||
|
|
@ -361,7 +364,7 @@
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<WrapPanel Grid.Column="1" HorizontalAlignment="Right">
|
<WrapPanel Grid.Column="1" HorizontalAlignment="Right">
|
||||||
<Button Content="Cancel"
|
<Button Content="Back"
|
||||||
Command="{Binding BackCommand}"
|
Command="{Binding BackCommand}"
|
||||||
Style="{StaticResource OutlineButtonStyle}"
|
Style="{StaticResource OutlineButtonStyle}"
|
||||||
MinWidth="120"
|
MinWidth="120"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"SyncApiEndpoint": "https://api.example.com/uind/sync",
|
||||||
|
"ScannerConnected": false,
|
||||||
|
"ScanTimeoutSeconds": 60,
|
||||||
|
"AdminCardId": "ADMIN",
|
||||||
|
"SiteId": "02",
|
||||||
|
"DeviceId": "",
|
||||||
|
"RememberAdminCredentials": false,
|
||||||
|
"SavedAdminUsername": "",
|
||||||
|
"SavedAdminPasswordProtected": "",
|
||||||
|
"MySqlConnectionString": "Server=localhost;Database=hrms;User=root;Password=CHANGE_ME;Port=3306;"
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue