using AVSCartonShipmentVerifier.DTOs; using AVSCartonShipmentVerifier.Configuration; using AVSCartonShipmentVerifier.ScanHistory; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace AVSCartonShipmentVerifier.Services; public sealed class SqliteScanHistoryStore( ScanHistoryDbContext dbContext, IOptions options, ILogger logger) : IScanHistoryStore { private readonly ScanHistoryDbContext _dbContext = dbContext; private readonly ScanHistoryOptions _options = options.Value; private readonly ILogger _logger = logger; public async Task AddSuccessfulScanAsync(ScannedRecordDto record, CancellationToken cancellationToken = default) { try { _dbContext.ScanHistoryEntries.Add(new ScanHistoryEntry { ModelNumber = record.ModelNumber, UniqueNumber = record.UniqueNumber, ExistsInSystem = record.ExistsInSystem, ShipmentMarked = record.ShipmentMarked, ProcessedAtUtc = record.ProcessedAtUtc }); await _dbContext.SaveChangesAsync(cancellationToken); await TrimSuccessfulAsync(cancellationToken); } catch (Exception exception) { _logger.LogError(exception, "Failed to persist successful scan history entry."); } } public async Task AddRejectedScanAsync(RejectedScanRecordDto record, CancellationToken cancellationToken = default) { try { _dbContext.RejectedScanEntries.Add(new RejectedScanEntry { RawInputValue = record.RawInputValue, ModelNumber = record.ModelNumber, UniqueNumber = record.UniqueNumber, RejectionReason = record.RejectionReason, ProcessedAtUtc = record.ProcessedAtUtc }); await _dbContext.SaveChangesAsync(cancellationToken); await TrimRejectedAsync(cancellationToken); } catch (Exception exception) { _logger.LogError(exception, "Failed to persist rejected scan history entry."); } } public async Task> GetLastFiveSuccessfulScansAsync(CancellationToken cancellationToken = default) { try { var entries = await _dbContext.ScanHistoryEntries .AsNoTracking() .Where(x => x.ShipmentMarked) .OrderByDescending(x => x.ProcessedAtUtc) .Take(5) .ToArrayAsync(cancellationToken); return MapSuccessful(entries); } catch (Exception exception) when (TryHandleReadCancellation(exception, cancellationToken, "Successful scan history (last five)")) { return Array.Empty(); } catch (Exception exception) { _logger.LogError(exception, "Failed to read successful scan history."); return Array.Empty(); } } public async Task> GetLastFiveRejectedScansAsync(CancellationToken cancellationToken = default) { try { var entries = await _dbContext.RejectedScanEntries .AsNoTracking() .OrderByDescending(x => x.ProcessedAtUtc) .Take(5) .ToArrayAsync(cancellationToken); return MapRejected(entries); } catch (Exception exception) when (TryHandleReadCancellation(exception, cancellationToken, "Rejected scan history (last five)")) { return Array.Empty(); } catch (Exception exception) { _logger.LogError(exception, "Failed to read rejected scan history."); return Array.Empty(); } } public async Task> GetAllSuccessfulScansAsync(CancellationToken cancellationToken = default) { try { var entries = await _dbContext.ScanHistoryEntries .AsNoTracking() .Where(x => x.ShipmentMarked) .OrderByDescending(x => x.ProcessedAtUtc) .ToArrayAsync(cancellationToken); return MapSuccessful(entries); } catch (Exception exception) when (TryHandleReadCancellation(exception, cancellationToken, "Successful scan history (all)")) { return Array.Empty(); } catch (Exception exception) { _logger.LogError(exception, "Failed to read full successful scan history."); return Array.Empty(); } } public async Task> GetAllRejectedScansAsync(CancellationToken cancellationToken = default) { try { var entries = await _dbContext.RejectedScanEntries .AsNoTracking() .OrderByDescending(x => x.ProcessedAtUtc) .ToArrayAsync(cancellationToken); return MapRejected(entries); } catch (Exception exception) when (TryHandleReadCancellation(exception, cancellationToken, "Rejected scan history (all)")) { return Array.Empty(); } catch (Exception exception) { _logger.LogError(exception, "Failed to read full rejected scan history."); return Array.Empty(); } } private bool TryHandleReadCancellation(Exception exception, CancellationToken cancellationToken, string operation) { if (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { return false; } _logger.LogDebug(exception, "{Operation} read was cancelled.", operation); return true; } private static ScannedRecordDto[] MapSuccessful(IEnumerable entries) => entries .Select(x => new ScannedRecordDto { ModelNumber = x.ModelNumber, UniqueNumber = x.UniqueNumber, ExistsInSystem = x.ExistsInSystem, ShipmentMarked = x.ShipmentMarked, ProcessedAtUtc = DateTime.SpecifyKind(x.ProcessedAtUtc, DateTimeKind.Utc) }) .ToArray(); private static RejectedScanRecordDto[] MapRejected(IEnumerable entries) => entries .Select(x => new RejectedScanRecordDto { RawInputValue = x.RawInputValue, ModelNumber = x.ModelNumber, UniqueNumber = x.UniqueNumber, RejectionReason = x.RejectionReason, ProcessedAtUtc = DateTime.SpecifyKind(x.ProcessedAtUtc, DateTimeKind.Utc) }) .ToArray(); private async Task TrimSuccessfulAsync(CancellationToken cancellationToken) { var max = _options.MaxRecordsToKeep; if (max <= 0) { return; } var toDelete = await _dbContext.ScanHistoryEntries .OrderByDescending(x => x.ProcessedAtUtc) .Skip(max) .Select(x => x.Id) .ToArrayAsync(cancellationToken); if (toDelete.Length == 0) { return; } _dbContext.ScanHistoryEntries.RemoveRange(toDelete.Select(id => new ScanHistoryEntry { Id = id })); await _dbContext.SaveChangesAsync(cancellationToken); } private async Task TrimRejectedAsync(CancellationToken cancellationToken) { var max = _options.MaxRecordsToKeep; if (max <= 0) { return; } var toDelete = await _dbContext.RejectedScanEntries .OrderByDescending(x => x.ProcessedAtUtc) .Skip(max) .Select(x => x.Id) .ToArrayAsync(cancellationToken); if (toDelete.Length == 0) { return; } _dbContext.RejectedScanEntries.RemoveRange(toDelete.Select(id => new RejectedScanEntry { Id = id })); await _dbContext.SaveChangesAsync(cancellationToken); } }