using FinlyticAssets.Database; using FinlyticAssets.Entities; using FinlyticCore.Entities.Assets; using FinlyticCore.Models.TradeRepublic; using FinlyticCore.Services.TradeRepublic; using Microsoft.EntityFrameworkCore; namespace FinlyticAssets.Services; /// /// Defines database operations for managing Trade Republic asset entities. /// public interface IAssetsDbService { public Task> GetAllValidAssetsAsync(); public Task AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset); public Task AddOrUpdateAssetsAsync(IEnumerable dtoAssets); public Task> GetAssetsByIsinAsync(string isin); public Task> GetValidAssetsByIsinAsync(string isin); public Task> FindAffectedActiveAssetsAsync(string searchQuery); public Task UpdateAssetImageIdAsync(string isin, string imageId); public Task DeleteAssetAsync(string isin); public Task> GetDiscoveryAssetsAsync(int limit = 15); } /// public class AssetsDbService : IAssetsDbService { private readonly AssetsDbContext _context; private readonly ITradeRepublicService _tradeRepublicService; private readonly ILogger _logger; public AssetsDbService(AssetsDbContext context, ILogger logger, ITradeRepublicService tradeRepublicService) { _context = context; _logger = logger; _tradeRepublicService = tradeRepublicService; } /// Inherits documentation from interface. public async Task> GetDiscoveryAssetsAsync(int limit = 15) { var cutoff = DateTime.UtcNow.AddDays(-90); var validAssets = await _context.TradeRepublicAssets .AsNoTracking() .Include(a => a.Tags) .Where(a => a.LastUpdatedAt >= cutoff && !string.IsNullOrEmpty(a.Name)) .ToListAsync(); if (validAssets.Count == 0) return []; var scored = validAssets .Select(a => new { Asset = a, Score = (a.Tags?.Count ?? 0) * 10 + (a.HasCfd ? 5 : 0) + (string.IsNullOrEmpty(a.ImageId) ? 0 : 15) + (a.Name.Length > 3 ? 5 : 0) }) .OrderByDescending(x => x.Score) .ThenByDescending(x => x.Asset.LastUpdatedAt) .ToList(); var result = new List(); var grouped = scored.GroupBy(x => x.Asset.Type).ToList(); int index = 0; while (result.Count < limit && grouped.Any(g => g.Any())) { bool addedAny = false; foreach (var group in grouped) { var item = group.Skip(index).FirstOrDefault(); if (item != null) { result.Add(item.Asset); addedAny = true; if (result.Count >= limit) break; } } index++; if (!addedAny) break; } return result; } /// Inherits documentation from interface. public async Task> GetAllValidAssetsAsync() { var cutoff = DateTime.UtcNow.AddDays(-90); return await _context.TradeRepublicAssets .AsNoTracking() .Include(a => a.Tags) .Where(a => a.LastUpdatedAt >= cutoff) .ToListAsync(); } /// Inherits documentation from interface. public async Task AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset) { var existingEntity = await _context.TradeRepublicAssets .Include(a => a.Tags) .FirstOrDefaultAsync(a => a.Isin == dtoAsset.Isin); var now = DateTime.UtcNow; var mappedTags = new List(); foreach (var tagDto in dtoAsset.Tags ?? Array.Empty()) { var existingTag = await _context.TradeRepublicTags.FirstOrDefaultAsync(t => t.Id == tagDto.Id); if (existingTag == null) { _logger.LogTrace("Creating missing asset tag object in storage cache context. ID: {TagId}", tagDto.Id); existingTag = new TagEntity { Id = tagDto.Id, Name = tagDto.Name, Type = tagDto.Type }; await _context.TradeRepublicTags.AddAsync(existingTag); } mappedTags.Add(existingTag); } if (existingEntity == null) { _logger.LogDebug("Asset with ISIN {Isin} not found. Mapping and creating a new entity.", dtoAsset.Isin); var newEntity = MapDtoToEntity(dtoAsset); newEntity.LastUpdatedAt = now; newEntity.Tags = mappedTags; await _context.TradeRepublicAssets.AddAsync(newEntity); await _context.SaveChangesAsync(); return true; } _logger.LogDebug("Asset with ISIN {Isin} exists. Merging properties and updating database record.", dtoAsset.Isin); existingEntity.Name = dtoAsset.Name; existingEntity.Type = dtoAsset.Type; existingEntity.InstrumentCategory = dtoAsset.InstrumentCategory; existingEntity.HasCfd = dtoAsset.HasCfd; existingEntity.ImageId = dtoAsset.ImageId; existingEntity.LastUpdatedAt = now; UpdateSubtypeProperties(existingEntity, dtoAsset); existingEntity.Tags = mappedTags; _context.TradeRepublicAssets.Update(existingEntity); await _context.SaveChangesAsync(); return true; } /// Inherits documentation from interface. public async Task AddOrUpdateAssetsAsync(IEnumerable dtoAssets) { var assetsList = dtoAssets.ToList(); if (assetsList.Count == 0) return 0; var now = DateTime.UtcNow; int changedCount = 0; var isins = assetsList.Select(a => a.Isin).Distinct().ToList(); var existingAssets = await _context.TradeRepublicAssets .Include(a => a.Tags) .Where(a => isins.Contains(a.Isin)) .ToDictionaryAsync(a => a.Isin); var tagIds = assetsList .SelectMany(a => a.Tags ?? Array.Empty()) .Select(t => t.Id) .Distinct() .ToList(); var tagCache = await _context.TradeRepublicTags .Where(t => tagIds.Contains(t.Id)) .ToDictionaryAsync(t => t.Id); foreach (var dto in assetsList) { var isChanged = false; var existingEntity = existingAssets.GetValueOrDefault(dto.Isin); var mappedTags = new List(); foreach (var tagDto in dto.Tags ?? Array.Empty()) { if (!tagCache.TryGetValue(tagDto.Id, out var tagEntity)) { _logger.LogTrace("Creating missing asset tag object in storage cache context. ID: {TagId}", tagDto.Id); tagEntity = new TagEntity { Id = tagDto.Id, Name = tagDto.Name, Type = tagDto.Type }; await _context.TradeRepublicTags.AddAsync(tagEntity); tagCache.Add(tagDto.Id, tagEntity); } mappedTags.Add(tagEntity); } if (existingEntity == null) { _logger.LogDebug("Asset with ISIN {Isin} not found. Mapping and creating a new entity.", dto.Isin); var newEntity = MapDtoToEntity(dto); newEntity.LastUpdatedAt = now; newEntity.Tags = mappedTags; await _context.TradeRepublicAssets.AddAsync(newEntity); isChanged = true; } else { _logger.LogDebug("Asset with ISIN {Isin} exists. Merging properties and updating database record.", dto.Isin); if (existingEntity.Name != dto.Name || existingEntity.Type != dto.Type || existingEntity.InstrumentCategory != dto.InstrumentCategory || existingEntity.HasCfd != dto.HasCfd || existingEntity.ImageId != dto.ImageId || !existingEntity.Tags.SequenceEqual(mappedTags)) { existingEntity.Name = dto.Name; existingEntity.Type = dto.Type; existingEntity.InstrumentCategory = dto.InstrumentCategory; existingEntity.HasCfd = dto.HasCfd; existingEntity.ImageId = dto.ImageId; existingEntity.LastUpdatedAt = now; UpdateSubtypeProperties(existingEntity, dto); existingEntity.Tags = mappedTags; _context.TradeRepublicAssets.Update(existingEntity); isChanged = true; } } if (isChanged) { changedCount++; } } if (changedCount > 0) { await _context.SaveChangesAsync(); } return changedCount; } /// Inherits documentation from interface. public async Task> GetAssetsByIsinAsync(string isin) { var localAssets = await _context.TradeRepublicAssets .Include(a => a.Tags) .Where(a => a.Isin == isin) .ToListAsync(); if (localAssets.Count > 0) { return localAssets; } // JIT-Fetch via API var trAssetDto = await _tradeRepublicService.GetAsset(isin); if (trAssetDto?.Results != null && trAssetDto.Results.Count > 0) { foreach (var asset in trAssetDto.Results) { await AddOrUpdateAssetAsync(asset); } return await _context.TradeRepublicAssets .Include(a => a.Tags) .Where(a => a.Isin == isin) .ToListAsync(); } return []; } /// Inherits documentation from interface. public async Task> GetValidAssetsByIsinAsync(string isin) { var cutoff = DateTime.UtcNow.AddDays(-14); return await _context.TradeRepublicAssets .AsNoTracking() .Include(a => a.Tags) .Where(a => a.Isin == isin && a.LastUpdatedAt >= cutoff) .ToListAsync(); } /// Inherits documentation from interface. public async Task> FindAffectedActiveAssetsAsync(string searchQuery) { if (string.IsNullOrWhiteSpace(searchQuery)) return []; var cutoff = DateTime.UtcNow.AddDays(-14); var searchTerms = searchQuery .Split(',') .Select(t => t.Trim()) .Where(t => !string.IsNullOrEmpty(t)) .Distinct() .ToList(); if (searchTerms.Count == 0) return []; var query = _context.TradeRepublicAssets .AsNoTracking() .Where(a => a.LastUpdatedAt >= cutoff) .Include(a => a.Tags) .AsQueryable(); foreach (var term in searchTerms) { var lowerTerm = term.ToLower(); query = query.Where(a => a.Isin.ToLower().Contains(lowerTerm) || a.Name.ToLower().Contains(lowerTerm) || a.Tags.Any(tag => tag.Name.ToLower().Contains(lowerTerm))); } var localAssets = await query.ToListAsync(); var possibleIsins = searchTerms .Where(t => t.Length == 12 && char.IsLetter(t[0]) && char.IsLetter(t[1])) .Select(t => t.ToUpper()) .ToList(); if (possibleIsins.Count > 0) { var foundIsins = localAssets.Select(a => a.Isin).ToHashSet(); var missingIsins = possibleIsins.Where(isin => !foundIsins.Contains(isin)).ToList(); if (missingIsins.Count > 0) { var fetchedNewAsset = false; foreach (var missingIsin in missingIsins) { var trAssetDto = await _tradeRepublicService.GetAsset(missingIsin); if (trAssetDto?.Results != null) { foreach (var asset in trAssetDto.Results) { await AddOrUpdateAssetAsync(asset); } fetchedNewAsset = true; } } if (fetchedNewAsset) { return await query.ToListAsync(); } } } return localAssets; } /// Inherits documentation from interface. public async Task UpdateAssetImageIdAsync(string isin, string imageId) { var existingAssets = await _context.TradeRepublicAssets .Where(a => a.Isin == isin) .ToListAsync(); if (existingAssets.Count > 0) { foreach (var asset in existingAssets) { asset.ImageId = imageId; } await _context.SaveChangesAsync(); _logger.LogInformation("[{Channel}] Updated ImageId for ISIN {Isin} in database to '{ImageId}'", "AssetsChannel", isin, imageId); } } /// Inherits documentation from interface. public async Task DeleteAssetAsync(string isin) { var asset = await _context.TradeRepublicAssets.FirstOrDefaultAsync(a => a.Isin == isin); if (asset == null) { _logger.LogWarning("[{Channel}] Delete execution cancelled. Asset with ISIN {Isin} does not exist.", "AssetsChannel", isin); return false; } _context.TradeRepublicAssets.Remove(asset); await _context.SaveChangesAsync(); _logger.LogInformation("[{Channel}] Asset with ISIN {Isin} has been successfully deleted.", "AssetsChannel", isin); return true; } #region Helper & Mapping Methods private AssetEntity MapDtoToEntity(TradeRepublicAsset dto) { AssetEntity entity = dto switch { TradeRepublicStock stock => new StockEntity { Isin = stock.Isin, DerivativeProductCategories = stock.DerivativeProductCategories.ToList() }, TradeRepublicCrypto crypto => new CryptoEntity { Isin = crypto.Isin, Subtitle = crypto.Subtitle, SearchSubtitle = crypto.SearchSubtitle }, TradeRepublicEtf etf => new EtfEntity { Isin = etf.Isin, EtfDescription = etf.EtfDescription, MappedEtfIndexName = etf.MappedEtfIndexName, Subtitle = etf.Subtitle, SearchSubtitle = etf.SearchSubtitle, DerivativeProductCategories = etf.DerivativeProductCategories.ToList() }, TradeRepublicSynthetic synth => new SyntheticEntity { Isin = synth.Isin, DerivativeProductCategories = synth.DerivativeProductCategories.ToList() }, TradeRepublicBond bond => new BondEntity { Isin = bond.Isin, BondIssuerName = bond.BondIssuerName, SearchSubtitle = bond.SearchSubtitle }, TradeRepublicDerivative deriv => new DerivativeEntity { Isin = deriv.Isin, UnderlyingIsin = deriv.UnderlyingIsin, DerivativeProductCategories = deriv.DerivativeProductCategories.ToList() }, _ => throw new NotSupportedException($"Type {dto.GetType().Name} is not supported.") }; return PopulateBaseProperties(entity, dto); } private AssetEntity PopulateBaseProperties(AssetEntity entity, TradeRepublicAsset dto) { entity.Name = dto.Name; entity.Type = dto.Type; entity.InstrumentCategory = dto.InstrumentCategory; entity.HasCfd = dto.HasCfd; entity.ImageId = dto.ImageId; return entity; } private void UpdateSubtypeProperties(AssetEntity entity, TradeRepublicAsset dto) { switch (entity) { case StockEntity stockEntity when dto is TradeRepublicStock stockDto: stockEntity.DerivativeProductCategories = stockDto.DerivativeProductCategories.ToList(); break; case CryptoEntity cryptoEntity when dto is TradeRepublicCrypto cryptoDto: cryptoEntity.Subtitle = cryptoDto.Subtitle; cryptoEntity.SearchSubtitle = cryptoDto.SearchSubtitle; break; case EtfEntity etfEntity when dto is TradeRepublicEtf etfDto: etfEntity.DerivativeProductCategories = etfDto.DerivativeProductCategories.ToList(); etfEntity.EtfDescription = etfDto.EtfDescription; etfEntity.MappedEtfIndexName = etfDto.MappedEtfIndexName; etfEntity.Subtitle = etfDto.Subtitle; etfEntity.SearchSubtitle = etfDto.SearchSubtitle; break; case SyntheticEntity synthEntity when dto is TradeRepublicSynthetic synthDto: synthEntity.DerivativeProductCategories = synthDto.DerivativeProductCategories.ToList(); break; case BondEntity bondEntity when dto is TradeRepublicBond bondDto: bondEntity.BondIssuerName = bondDto.BondIssuerName; bondEntity.SearchSubtitle = bondDto.SearchSubtitle; break; case DerivativeEntity derivEntity when dto is TradeRepublicDerivative derivDto: derivEntity.DerivativeProductCategories = derivDto.DerivativeProductCategories.ToList(); derivEntity.UnderlyingIsin = derivDto.UnderlyingIsin; break; } } #endregion }