using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Purrse.Api.Hubs; using Purrse.Core.Interfaces.Services; using Purrse.Data; namespace Purrse.Api.Services; public class FileWatcherService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly IHubContext _hubContext; private readonly string _importsPath; private FileSystemWatcher? _watcher; private static readonly string[] SupportedExtensions = { ".ofx", ".qfx", ".csv", ".qif" }; public FileWatcherService( IServiceScopeFactory scopeFactory, ILogger logger, IConfiguration config, IHubContext hubContext) { _scopeFactory = scopeFactory; _logger = logger; _hubContext = hubContext; _importsPath = config["Imports:WatchPath"] ?? Path.Combine(AppContext.BaseDirectory, "imports"); } protected override Task ExecuteAsync(CancellationToken stoppingToken) { if (!Directory.Exists(_importsPath)) Directory.CreateDirectory(_importsPath); var archivePath = Path.Combine(_importsPath, "archive"); if (!Directory.Exists(archivePath)) Directory.CreateDirectory(archivePath); _watcher = new FileSystemWatcher(_importsPath) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, EnableRaisingEvents = true, IncludeSubdirectories = false }; _watcher.Created += OnFileCreated; _logger.LogInformation("File watcher started, monitoring: {Path}", _importsPath); stoppingToken.Register(() => { _watcher?.Dispose(); _logger.LogInformation("File watcher stopped"); }); return Task.CompletedTask; } private async void OnFileCreated(object sender, FileSystemEventArgs e) { try { var extension = Path.GetExtension(e.FullPath).ToLowerInvariant(); if (!SupportedExtensions.Contains(extension)) { _logger.LogDebug("Ignoring non-supported file: {FileName}", e.Name); return; } _logger.LogInformation("New import file detected: {FileName}", e.Name); // Wait for file to finish writing await WaitForFileReady(e.FullPath); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var importService = scope.ServiceProvider.GetRequiredService(); // Find the target account by matching filename to account name // In multi-user setups, the filename format is "username--accountname.ext" // If no separator, match against all accounts (single-user mode) var fileNameWithoutExt = Path.GetFileNameWithoutExtension(e.Name); string? targetUsername = null; string accountMatch = fileNameWithoutExt!; if (fileNameWithoutExt!.Contains("--")) { var parts = fileNameWithoutExt.Split("--", 2); targetUsername = parts[0]; accountMatch = parts[1]; } var accountQuery = db.Accounts .Include(a => a.User) .Where(a => a.IsActive && !a.IsClosed); if (targetUsername != null) accountQuery = accountQuery.Where(a => a.User.Username.ToLower() == targetUsername.ToLower()); var account = await accountQuery .FirstOrDefaultAsync(a => a.Name.ToLower() == accountMatch.ToLower()); // Fall back to first active account only if no username filter (single-user mode) if (account == null && targetUsername == null) { account = await db.Accounts .Where(a => a.IsActive && !a.IsClosed) .OrderBy(a => a.SortOrder) .FirstOrDefaultAsync(); } if (account == null) { _logger.LogWarning("No active account found for file import: {FileName}", e.Name); return; } _logger.LogInformation("Importing {FileName} into account {AccountName} ({AccountId})", e.Name, account.Name, account.Id); await using var fileStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read, FileShare.Read); var result = await importService.UploadAsync(account.UserId, account.Id, e.Name!, fileStream); _logger.LogInformation( "File import complete: {FileName} - {Total} transactions ({New} new, {Duplicates} duplicates)", e.Name, result.TotalTransactions, result.NewTransactions, result.PossibleDuplicates); // Send SignalR notification to the account owner await _hubContext.Clients.Group(account.UserId.ToString()).SendAsync("FileImportReady", new { batchId = result.BatchId, fileName = e.Name, accountId = account.Id, accountName = account.Name, totalTransactions = result.TotalTransactions, newTransactions = result.NewTransactions, possibleDuplicates = result.PossibleDuplicates }); // Archive the processed file ArchiveFile(e.FullPath); } catch (Exception ex) { _logger.LogError(ex, "Error processing imported file: {FileName}", e.Name); } } private static async Task WaitForFileReady(string filePath, int maxRetries = 30, int delayMs = 500) { for (int i = 0; i < maxRetries; i++) { try { using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None); return; // File is ready } catch (IOException) { await Task.Delay(delayMs); } } throw new IOException($"File {filePath} was not ready after {maxRetries * delayMs}ms"); } private void ArchiveFile(string filePath) { try { var archivePath = Path.Combine(_importsPath, "archive"); var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); var fileName = Path.GetFileName(filePath); var archiveFileName = $"{timestamp}_{fileName}"; var destination = Path.Combine(archivePath, archiveFileName); File.Move(filePath, destination); _logger.LogInformation("Archived import file to: {ArchivePath}", destination); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to archive file: {FilePath}", filePath); } } }