365 lines
13 KiB
C#
365 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using FinlyticCore.Dtos.News;
|
|
using FinlyticCore.Services;
|
|
using FinlyticNews.Database;
|
|
using FinlyticNews.Entities;
|
|
using FinlyticNews.Util;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace FinlyticNews.Services;
|
|
|
|
/// <summary>
|
|
/// Defines database persistence operations for news articles and sources following the FinlyticNews lifecycle pipeline.
|
|
/// </summary>
|
|
public interface INewsDbService
|
|
{
|
|
Task<List<ArticleSourceEntity>> GetSourcesAsync();
|
|
Task<bool> IsUrlDuplicateAsync(string url);
|
|
|
|
/// <summary>
|
|
/// Phase 1: Discovers and locks a new article URL by setting its status to "Pending".
|
|
/// </summary>
|
|
Task<NewsArticleEntity?> CreatePendingArticleAsync(
|
|
string url,
|
|
List<string>? discoveredIsins = null,
|
|
string? title = null,
|
|
string? summary = null,
|
|
DateTime? publishedAt = null,
|
|
string? language = null);
|
|
|
|
/// <summary>
|
|
/// Transitions the lifecycle state of an article (e.g. Pending -> Processing -> Scraping / Failed / Completed / Analyzed).
|
|
/// </summary>
|
|
Task UpdateArticleStatusAsync(Guid id, string status);
|
|
|
|
/// <summary>
|
|
/// Deletes an article from the database entirely (e.g. when it fails or is a duplicate).
|
|
/// </summary>
|
|
Task DeleteArticleAsync(Guid id);
|
|
|
|
/// <summary>
|
|
/// Updates the target URL of an article if a redirect is resolved during the Processing phase.
|
|
/// </summary>
|
|
Task UpdateArticleUrlAsync(Guid id, string resolvedUrl);
|
|
|
|
/// <summary>
|
|
/// Saves the processed article content, hashes, and matched assets, and transitions lifecycle status to "Completed".
|
|
/// </summary>
|
|
Task<NewsArticleEntity?> SaveProcessedArticleAsync(
|
|
Guid id,
|
|
string title,
|
|
string? author,
|
|
string? summary,
|
|
string contentRaw,
|
|
string? language,
|
|
DateTime? publishedAt,
|
|
string titleHash,
|
|
long simHash,
|
|
List<MatchedAssetEntity> matchedAssets);
|
|
|
|
Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(string status);
|
|
|
|
Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
|
|
int limit,
|
|
int offset,
|
|
string? isin = null,
|
|
DateTime? date = null,
|
|
string? status = null,
|
|
string? searchQuery = null);
|
|
|
|
Task<NewsArticleEntity?> GetArticleByIdAsync(Guid id);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public class NewsDbService : INewsDbService
|
|
{
|
|
private readonly NewsDbContext _context;
|
|
private readonly IFinlyticLogger<NewsDbService> _finlyticLogger;
|
|
|
|
public NewsDbService(NewsDbContext context, IFinlyticLogger<NewsDbService> finlyticLogger)
|
|
{
|
|
_context = context;
|
|
_finlyticLogger = finlyticLogger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<List<ArticleSourceEntity>> GetSourcesAsync()
|
|
{
|
|
return await _context.ArticleSources.AsNoTracking().ToListAsync();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<NewsArticleEntity?> GetArticleByIdAsync(Guid id)
|
|
{
|
|
return await _context.NewsArticles
|
|
.Include(a => a.MatchedAssets)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(a => a.Id == id);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> IsUrlDuplicateAsync(string url)
|
|
{
|
|
var trimmedUrl = url.Trim();
|
|
return await _context.NewsArticles
|
|
.AsNoTracking()
|
|
.AnyAsync(a => a.SourceUrl == trimmedUrl);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<NewsArticleEntity?> CreatePendingArticleAsync(
|
|
string url,
|
|
List<string>? discoveredIsins = null,
|
|
string? title = null,
|
|
string? summary = null,
|
|
DateTime? publishedAt = null,
|
|
string? language = null)
|
|
{
|
|
var trimmedUrl = url.Trim();
|
|
var cleanUrl = trimmedUrl.TrimEnd('.', '/');
|
|
|
|
var existingArticle = await _context.NewsArticles
|
|
.FirstOrDefaultAsync(a => a.SourceUrl == trimmedUrl || a.SourceUrl == cleanUrl || a.SourceUrl.StartsWith(cleanUrl));
|
|
|
|
if (existingArticle != null)
|
|
{
|
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Article URL already exists (Duplicate hit): {Url}", trimmedUrl);
|
|
return null;
|
|
}
|
|
|
|
var finalPublishedAt = publishedAt.HasValue
|
|
? (publishedAt.Value.Kind == DateTimeKind.Unspecified
|
|
? DateTime.SpecifyKind(publishedAt.Value, DateTimeKind.Utc)
|
|
: publishedAt.Value.ToUniversalTime())
|
|
: DateTime.UtcNow;
|
|
|
|
if (finalPublishedAt > DateTime.UtcNow)
|
|
{
|
|
finalPublishedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
var article = new NewsArticleEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Title = !string.IsNullOrWhiteSpace(title) ? title : "Pending Discovery",
|
|
Summary = !string.IsNullOrWhiteSpace(summary) ? summary : "Extraction in progress...",
|
|
ContentRaw = "Extraction in progress...",
|
|
SourceUrl = trimmedUrl,
|
|
Language = language,
|
|
ScrapedAt = DateTime.UtcNow,
|
|
PublishedAt = finalPublishedAt,
|
|
Status = "Pending"
|
|
};
|
|
|
|
if (discoveredIsins != null && discoveredIsins.Count > 0)
|
|
{
|
|
foreach (var isin in discoveredIsins)
|
|
{
|
|
article.MatchedAssets.Add(new MatchedAssetEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
NewsArticleId = article.Id,
|
|
Isin = isin,
|
|
Name = isin
|
|
});
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
_context.NewsArticles.Add(article);
|
|
await _context.SaveChangesAsync();
|
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Registered new article with status 'Pending'. ID: {Id}, Url: {Url}", article.Id, trimmedUrl);
|
|
}
|
|
catch (DbUpdateException ex)
|
|
{
|
|
_context.ChangeTracker.Clear();
|
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Unique constraint or concurrency hit during insert for URL: {Url}. Fetching existing fallback. ({Message})", trimmedUrl, ex.InnerException?.Message ?? ex.Message);
|
|
var existing = await _context.NewsArticles.AsNoTracking().FirstOrDefaultAsync(a => a.SourceUrl == trimmedUrl || a.SourceUrl == cleanUrl || a.SourceUrl.StartsWith(cleanUrl));
|
|
if (existing != null)
|
|
{
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return article;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task UpdateArticleStatusAsync(Guid id, string status)
|
|
{
|
|
var rowsAffected = await _context.NewsArticles
|
|
.Where(a => a.Id == id)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(a => a.Status, status));
|
|
|
|
if (rowsAffected == 0)
|
|
{
|
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Attempted status transition for non-existing article. ID: {Id}", id);
|
|
}
|
|
else
|
|
{
|
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Transitioned article {Id} to status '{Status}'", id, status);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteArticleAsync(Guid id)
|
|
{
|
|
var rowsAffected = await _context.NewsArticles
|
|
.Where(a => a.Id == id)
|
|
.ExecuteDeleteAsync();
|
|
|
|
if (rowsAffected > 0)
|
|
{
|
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Deleted unused/failed article {Id} from database", id);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task UpdateArticleUrlAsync(Guid id, string resolvedUrl)
|
|
{
|
|
var rowsAffected = await _context.NewsArticles
|
|
.Where(a => a.Id == id)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(a => a.SourceUrl, resolvedUrl));
|
|
|
|
if (rowsAffected == 0)
|
|
{
|
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Attempted URL update for non-existing article. ID: {Id}", id);
|
|
}
|
|
else
|
|
{
|
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Resolved redirect for article {Id} -> New URL: {Url}", id, resolvedUrl);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<NewsArticleEntity?> SaveProcessedArticleAsync(
|
|
Guid id,
|
|
string title,
|
|
string? author,
|
|
string? summary,
|
|
string contentRaw,
|
|
string? language,
|
|
DateTime? publishedAt,
|
|
string titleHash,
|
|
long simHash,
|
|
List<MatchedAssetEntity> matchedAssets)
|
|
{
|
|
var finalPublishedAt = publishedAt.HasValue
|
|
? (publishedAt.Value.Kind == DateTimeKind.Unspecified
|
|
? DateTime.SpecifyKind(publishedAt.Value, DateTimeKind.Utc)
|
|
: publishedAt.Value.ToUniversalTime())
|
|
: DateTime.UtcNow;
|
|
|
|
var rowsAffected = await _context.NewsArticles
|
|
.Where(a => a.Id == id)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(a => a.Title, title)
|
|
.SetProperty(a => a.Author, author)
|
|
.SetProperty(a => a.Summary, summary)
|
|
.SetProperty(a => a.ContentRaw, contentRaw)
|
|
.SetProperty(a => a.Language, language)
|
|
.SetProperty(a => a.TitleHash, titleHash)
|
|
.SetProperty(a => a.SimHash, simHash)
|
|
.SetProperty(a => a.Status, "Completed")
|
|
.SetProperty(a => a.PublishedAt, finalPublishedAt)
|
|
.SetProperty(a => a.ScrapedAt, DateTime.UtcNow));
|
|
|
|
if (rowsAffected == 0)
|
|
{
|
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Article with ID {Id} not found for completion update.", id);
|
|
return null;
|
|
}
|
|
|
|
// Cleanly delete existing matched assets and insert new ones
|
|
await _context.MatchedAssets.Where(m => m.NewsArticleId == id).ExecuteDeleteAsync();
|
|
|
|
if (matchedAssets != null && matchedAssets.Count > 0)
|
|
{
|
|
foreach (var asset in matchedAssets)
|
|
{
|
|
if (asset.Id == Guid.Empty)
|
|
{
|
|
asset.Id = Guid.NewGuid();
|
|
}
|
|
asset.NewsArticleId = id;
|
|
}
|
|
await _context.MatchedAssets.AddRangeAsync(matchedAssets);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var completedArticle = await _context.NewsArticles
|
|
.Include(a => a.MatchedAssets)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(a => a.Id == id);
|
|
|
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[Lifecycle] Article {Id} successfully processed and marked 'Completed'. Title: '{Title}'", id, title);
|
|
|
|
return completedArticle;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(string status)
|
|
{
|
|
return await _context.NewsArticles
|
|
.Include(a => a.MatchedAssets)
|
|
.Where(a => a.Status == status)
|
|
.OrderByDescending(a => a.PublishedAt)
|
|
.AsNoTracking()
|
|
.ToListAsync();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
|
|
int limit,
|
|
int offset,
|
|
string? isin = null,
|
|
DateTime? date = null,
|
|
string? status = null,
|
|
string? searchQuery = null)
|
|
{
|
|
var query = _context.NewsArticles
|
|
.Include(a => a.MatchedAssets)
|
|
.AsNoTracking()
|
|
.AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(isin))
|
|
{
|
|
var cleanIsin = isin.Trim();
|
|
query = query.Where(a => a.MatchedAssets.Any(m => m.Isin == cleanIsin));
|
|
}
|
|
|
|
if (date.HasValue)
|
|
{
|
|
var startUtc = date.Value.Date.ToUniversalTime();
|
|
var endUtc = startUtc.AddDays(1);
|
|
query = query.Where(a => a.PublishedAt >= startUtc && a.PublishedAt < endUtc);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
{
|
|
query = query.Where(a => a.Status == status);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
|
{
|
|
var cleanSearch = searchQuery.Trim().ToLower();
|
|
query = query.Where(a =>
|
|
a.Title.ToLower().Contains(cleanSearch) ||
|
|
(a.Summary != null && a.Summary.ToLower().Contains(cleanSearch)) ||
|
|
a.MatchedAssets.Any(m => m.Name.ToLower().Contains(cleanSearch)));
|
|
}
|
|
|
|
return await query
|
|
.OrderByDescending(a => a.PublishedAt)
|
|
.Skip(offset)
|
|
.Take(limit)
|
|
.ToListAsync();
|
|
}
|
|
} |