diff --git a/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs b/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs index fa5a09556..f7f4cf139 100644 --- a/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs +++ b/src/TechHub.Api/Endpoints/NewsletterEndpoints.cs @@ -279,10 +279,13 @@ private static async Task GetSendLogAsync( return Results.Ok(log); } - private static IResult TriggerNewsletterAsync(NewsletterBackgroundService backgroundService) + private static IResult TriggerNewsletterAsync( + [FromQuery] string? kind, + NewsletterBackgroundService backgroundService) { - backgroundService.TriggerImmediateRun(); - return Results.Accepted("/api/admin/newsletter/send-log", new { message = "Newsletter send triggered" }); + var normalizedKind = string.Equals(kind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; + backgroundService.TriggerImmediateRun(normalizedKind); + return Results.Accepted("/api/admin/newsletter/send-log", new { message = $"Newsletter {normalizedKind} send triggered" }); } private static IResult TestSendNewsletterAsync( diff --git a/src/TechHub.Api/Services/NewsletterBackgroundService.cs b/src/TechHub.Api/Services/NewsletterBackgroundService.cs index 1e145b61c..cb3d049d3 100644 --- a/src/TechHub.Api/Services/NewsletterBackgroundService.cs +++ b/src/TechHub.Api/Services/NewsletterBackgroundService.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Diagnostics.CodeAnalysis; using Dapper; using Microsoft.Extensions.Options; using TechHub.Core.Configuration; @@ -17,6 +18,7 @@ public sealed class NewsletterBackgroundService : BackgroundService private string? _pendingTestSendEmail; private IReadOnlyList? _pendingTestSections; private string? _pendingTestSendKind; + private string? _pendingManualKind; public NewsletterBackgroundService( IServiceProvider serviceProvider, @@ -35,11 +37,12 @@ public NewsletterBackgroundService( _logger = logger; } - public void TriggerImmediateRun() + public void TriggerImmediateRun(string kind = "roundup") { _pendingTestSendEmail = null; _pendingTestSections = null; _pendingTestSendKind = null; + _pendingManualKind = kind; _manualTrigger.TrySetResult(true); } @@ -104,6 +107,15 @@ private async Task RunManualAsync(CancellationToken ct) return; } + var manualKind = string.Equals(_pendingManualKind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; + _pendingManualKind = null; + + if (string.Equals(manualKind, "daily", StringComparison.OrdinalIgnoreCase)) + { + await SendDailyEmailsAsync(enforceHourGate: false, ct); + return; + } + await SendLatestRoundupsAsync(ct); } @@ -115,7 +127,8 @@ private async Task SendLatestRoundupsAsync(CancellationToken ct) const string Sql = """ SELECT DISTINCT ON (primary_section_name) - slug + slug AS Slug, + date_epoch AS DateEpoch FROM content_items WHERE collection_name = 'roundups' AND primary_section_name IS NOT NULL @@ -123,18 +136,42 @@ AND primary_section_name <> 'all' ORDER BY primary_section_name, date_epoch DESC """; - var slugs = (await connection.QueryAsync(new CommandDefinition(Sql, cancellationToken: ct))).AsList(); + var latestRoundups = (await connection.QueryAsync(new CommandDefinition(Sql, cancellationToken: ct))).AsList(); + if (latestRoundups.Count == 0) + { + return; + } + + var roundupTimeZone = ResolveRoundupTimeZone(); + var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, roundupTimeZone); + if (!latestRoundups.All(r => GetRoundupDate(r.DateEpoch, roundupTimeZone) == expectedMonday)) + { + _logger.LogInformation( + "Skipping roundup newsletter send because latest section roundups do not match expected Monday {ExpectedMonday}", + expectedMonday); + return; + } + + var slugs = latestRoundups.Select(r => r.Slug).ToList(); if (slugs.Count > 0) { - await newsletterService.SendCombinedWeeklyAsync(slugs, ct); + await newsletterService.SendCombinedWeeklyAsync( + slugs, + sendTargetKey: expectedMonday.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + ct); } } private async Task SendScheduledDailyEmailsAsync(CancellationToken ct) + { + await SendDailyEmailsAsync(enforceHourGate: true, ct); + } + + private async Task SendDailyEmailsAsync(bool enforceHourGate, CancellationToken ct) { var timeZone = ResolveTimeZone(_options.DailyDigestTimeZoneId); var localNow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); - if (localNow.Hour != _options.DailyDigestHourLocal) + if (enforceHourGate && localNow.Hour != _options.DailyDigestHourLocal) { return; } @@ -146,6 +183,9 @@ private async Task SendScheduledDailyEmailsAsync(CancellationToken ct) await newsletterService.SendAdminStatusReportAsync(day, ct); } + [SuppressMessage("Performance", "CA1812", Justification = "Instantiated by Dapper materialization.")] + private sealed record LatestSectionRoundupRow(string Slug, long DateEpoch); + private async Task IsEnabledAsync(CancellationToken ct) { // If disabled via environment variable (Newsletter__ScheduledSendEnabled=false), skip the @@ -215,4 +255,22 @@ private TimeZoneInfo ResolveTimeZone(string configuredId) return TimeZoneInfo.Utc; } + + private static TimeZoneInfo ResolveRoundupTimeZone() + { + return TimeZoneInfo.FindSystemTimeZoneById( + OperatingSystem.IsWindows() ? "Romance Standard Time" : "Europe/Brussels"); + } + + private static DateOnly GetExpectedRoundupMonday(DateTimeOffset utcNow, TimeZoneInfo roundupTimeZone) + { + var localDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(utcNow, roundupTimeZone).Date); + var daysSinceMonday = ((int)localDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; + return localDate.AddDays(-daysSinceMonday); + } + + private static DateOnly GetRoundupDate(long dateEpoch, TimeZoneInfo roundupTimeZone) + { + return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(dateEpoch), roundupTimeZone).Date); + } } diff --git a/src/TechHub.Core/Interfaces/INewsletterService.cs b/src/TechHub.Core/Interfaces/INewsletterService.cs index 53b552836..b488ef458 100644 --- a/src/TechHub.Core/Interfaces/INewsletterService.cs +++ b/src/TechHub.Core/Interfaces/INewsletterService.cs @@ -5,7 +5,7 @@ namespace TechHub.Core.Interfaces; public interface INewsletterService { Task SendRoundupNewsletterAsync(string roundupSlug, CancellationToken ct = default); - Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, CancellationToken ct = default); + Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, string? sendTargetKey = null, CancellationToken ct = default); Task SendTestEmailAsync(string roundupSlug, string recipientEmail, CancellationToken ct = default); Task SendTestWeeklyAsync(IReadOnlyList sections, string recipientEmail, CancellationToken ct = default); Task SendTestDailyEmailAsync(IReadOnlyList sections, string recipientEmail, CancellationToken ct = default); diff --git a/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs b/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs index cd6377aa6..d6069b8d4 100644 --- a/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs +++ b/src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs @@ -153,7 +153,7 @@ await _subscriberRepository.LogSendAsync( return sent; } - public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, CancellationToken ct = default) + public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlugs, string? sendTargetKey = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(roundupSlugs); if (roundupSlugs.Count == 0) @@ -161,44 +161,34 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu return false; } - if (!IsUnsubscribeSecretConfigured("combined-weekly")) - { - foreach (var slug in roundupSlugs) - { - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, 0, "failed", "Unsubscribe secret is not configured", ct); - } - - return false; - } - - // Skip slugs that have already been sent - var newSlugs = new List(roundupSlugs.Count); + // Load roundup content for each slug + var roundups = new List(roundupSlugs.Count); foreach (var slug in roundupSlugs) { - if (!await _subscriberRepository.HasBeenSentAsync("weekly-roundup", slug, ct)) + var roundup = await GetRoundupBySlugAsync(slug, ct); + if (roundup is not null) { - newSlugs.Add(slug); + roundups.Add(roundup); } } - if (newSlugs.Count == 0) + if (roundups.Count == 0) { return false; } - // Load roundup content for each new slug - var roundups = new List(newSlugs.Count); - foreach (var slug in newSlugs) + var targetKey = string.IsNullOrWhiteSpace(sendTargetKey) + ? $"batch:{string.Join(",", roundups.Select(r => r.Slug).OrderBy(s => s, StringComparer.OrdinalIgnoreCase))}" + : sendTargetKey.Trim(); + + if (await _subscriberRepository.HasBeenSentAsync("weekly-roundup", targetKey, ct)) { - var roundup = await GetRoundupBySlugAsync(slug, ct); - if (roundup is not null) - { - roundups.Add(roundup); - } + return false; } - if (roundups.Count == 0) + if (!IsUnsubscribeSecretConfigured("combined-weekly")) { + await _subscriberRepository.LogSendAsync("weekly-roundup", targetKey, 0, "failed", "Unsubscribe secret is not configured", ct); return false; } @@ -215,8 +205,8 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu var roundupsBySection = roundups.ToDictionary(r => r.SectionName, StringComparer.OrdinalIgnoreCase); - // Track per-slug delivery counts: (expected recipients, successful sends) - var slugStats = newSlugs.ToDictionary(s => s, _ => (Expected: 0, Actual: 0), StringComparer.OrdinalIgnoreCase); + var attempted = 0; + var successful = 0; try { @@ -227,16 +217,14 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu .Where(s => roundupsBySection.ContainsKey(s)) .Select(s => roundupsBySection[s]) .ToList(); + relevantRoundups = OrderRoundupsByWebsiteOrder(relevantRoundups); if (relevantRoundups.Count == 0) { continue; } - foreach (var r in relevantRoundups) - { - slugStats[r.Slug] = (slugStats[r.Slug].Expected + 1, slugStats[r.Slug].Actual); - } + attempted++; var unsubscribeUrl = BuildUnsubscribeUrl(subscriber.Email); var manageUrl = BuildManageUrl(subscriber.Email, _options.UnsubscribeSecret); @@ -248,49 +236,24 @@ public async Task SendCombinedWeeklyAsync(IReadOnlyList roundupSlu if (await SendEmailAsync(subscriber.Email, subject, html, text, ct)) { - foreach (var r in relevantRoundups) - { - slugStats[r.Slug] = (slugStats[r.Slug].Expected, slugStats[r.Slug].Actual + 1); - } + successful++; } } } catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { - foreach (var slug in newSlugs) - { - var actual = slugStats[slug].Actual; - var status = actual > 0 ? "partial" : "failed"; - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, actual, status, ex.Message, ct); - } - + var status = successful > 0 ? "partial" : "failed"; + await _subscriberRepository.LogSendAsync("weekly-roundup", targetKey, successful, status, ex.Message, ct); _logger.LogError(ex, "Failed sending combined weekly newsletter"); return false; } - // Log each slug with its individual delivery outcome - var anySent = false; - foreach (var slug in newSlugs) - { - var (expected, actual) = slugStats[slug]; - if (expected == 0) - { - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, 0, "sent", null, ct); - anySent = true; - } - else - { - var status = actual == expected ? "sent" : actual > 0 ? "partial" : "failed"; - var error = actual < expected ? $"Delivered to {actual} of {expected} subscribers" : null; - await _subscriberRepository.LogSendAsync("weekly-roundup", slug, actual, status, error, ct); - if (actual > 0) - { - anySent = true; - } - } - } - - return anySent; + var sendStatus = attempted == 0 || successful == attempted ? "sent" : successful > 0 ? "partial" : "failed"; + var sendError = attempted == 0 || successful == attempted + ? null + : $"Delivered to {successful} of {attempted} subscribers"; + await _subscriberRepository.LogSendAsync("weekly-roundup", targetKey, successful, sendStatus, sendError, ct); + return attempted == 0 || successful > 0; } public async Task SendTestWeeklyAsync(IReadOnlyList sections, string recipientEmail, CancellationToken ct = default) @@ -340,6 +303,8 @@ LIMIT 1 return false; } + roundups = OrderRoundupsByWebsiteOrder(roundups); + var logKey = $"test-weekly@{DateTime.UtcNow:yyyyMMddHHmmss}"; var unsubscribeUrl = BuildUnsubscribeUrl(recipientEmail); var manageUrl = BuildManageUrl(recipientEmail, _options.UnsubscribeSecret); @@ -376,7 +341,7 @@ public async Task SendTestDailyEmailAsync(IReadOnlyList sections, var yesterday = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)); var (start, end) = GetDayUtcWindow(yesterday); - var sectionNames = sections.Select(s => s.Trim().ToLowerInvariant()).ToList(); + var sectionNames = OrderSectionsByWebsiteOrder(sections); var itemsBySection = await GetDailyItemsBySectionAsync(sectionNames, start, end, ct); var html = BuildDailyOverviewHtml(yesterday, sectionNames, itemsBySection, recipientEmail); var text = BuildDailyOverviewText(yesterday, sectionNames, itemsBySection, recipientEmail); @@ -517,6 +482,7 @@ public async Task SendDailyOverviewAsync(DateOnly day, CancellationToken c var selectedSections = subscriber.DailySections .Where(section => itemsBySection.ContainsKey(section)) .ToList(); + selectedSections = OrderSectionsByWebsiteOrder(selectedSections); var html = BuildDailyOverviewHtml(day, selectedSections, itemsBySection, subscriber.Email); var text = BuildDailyOverviewText(day, selectedSections, itemsBySection, subscriber.Email); @@ -930,6 +896,37 @@ private string BuildCombinedWeeklyPlainText(IReadOnlyList roundups, private string GetSectionTitle(string slug) => _appSettings.Content.Sections.TryGetValue(slug, out var config) ? config.Title : slug; + private List OrderSectionsByWebsiteOrder(IEnumerable sectionNames) + { + var sectionOrder = _appSettings.Content.Sections + .Where(kvp => !string.Equals(kvp.Key, "all", StringComparison.OrdinalIgnoreCase)) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.Order, + StringComparer.OrdinalIgnoreCase); + + return sectionNames + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(s => sectionOrder.TryGetValue(s, out var order) ? order : int.MaxValue) + .ThenBy(s => GetSectionTitle(s), StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private List OrderRoundupsByWebsiteOrder(IEnumerable roundups) + { + var orderedSections = OrderSectionsByWebsiteOrder(roundups.Select(r => r.SectionName)); + var sectionIndex = orderedSections + .Select((section, index) => new { section, index }) + .ToDictionary(x => x.section, x => x.index, StringComparer.OrdinalIgnoreCase); + + return roundups + .OrderBy(r => sectionIndex.TryGetValue(r.SectionName, out var index) ? index : int.MaxValue) + .ThenBy(r => GetSectionTitle(r.SectionName), StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private string BuildDailyOverviewHtml( DateOnly day, IReadOnlyList sections, diff --git a/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor b/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor index c12144cb5..822210d19 100644 --- a/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor +++ b/src/TechHub.Web/Components/Pages/Admin/AdminNewsletter.razor @@ -16,7 +16,8 @@

Manage subscribers, remove bot signups, and trigger sends

- + +
@@ -233,14 +234,15 @@ } } - private async Task TriggerSendAsync() + private async Task TriggerSendAsync(string kind) { + var normalizedKind = string.Equals(kind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; try { _sending = true; _message = null; - await Api.TriggerNewsletterAsync(); - _message = "Newsletter send triggered."; + await Api.TriggerNewsletterAsync(normalizedKind); + _message = $"Newsletter {normalizedKind} send triggered."; _isError = false; } catch (HttpRequestException ex) diff --git a/src/TechHub.Web/Services/ITechHubApiClient.cs b/src/TechHub.Web/Services/ITechHubApiClient.cs index ba8a99767..297b3c726 100644 --- a/src/TechHub.Web/Services/ITechHubApiClient.cs +++ b/src/TechHub.Web/Services/ITechHubApiClient.cs @@ -320,7 +320,7 @@ Task GetCollectionRssFeedAsync( /// Trigger an immediate newsletter processing run. /// POST /api/admin/newsletter/trigger /// - Task TriggerNewsletterAsync(CancellationToken cancellationToken = default); + Task TriggerNewsletterAsync(string kind = "roundup", CancellationToken cancellationToken = default); /// /// Trigger a newsletter test send to a specific recipient for the given sections. diff --git a/src/TechHub.Web/Services/TechHubApiClient.cs b/src/TechHub.Web/Services/TechHubApiClient.cs index 407698ab3..63ee0deaa 100644 --- a/src/TechHub.Web/Services/TechHubApiClient.cs +++ b/src/TechHub.Web/Services/TechHubApiClient.cs @@ -994,12 +994,14 @@ public virtual async Task TriggerRoundupGenerationAsync(CancellationToken cancel } } - public virtual async Task TriggerNewsletterAsync(CancellationToken cancellationToken = default) + public virtual async Task TriggerNewsletterAsync(string kind = "roundup", CancellationToken cancellationToken = default) { + kind = string.Equals(kind?.Trim(), "daily", StringComparison.OrdinalIgnoreCase) ? "daily" : "roundup"; + try { - _logger.LogInformation("Triggering newsletter run"); - using var response = await _httpClient.PostAsync("/api/admin/newsletter/trigger", null, cancellationToken); + _logger.LogInformation("Triggering newsletter {Kind} run", kind); + using var response = await _httpClient.PostAsync($"/api/admin/newsletter/trigger?kind={kind}", null, cancellationToken); response.EnsureSuccessStatusCode(); } catch (HttpRequestException ex) diff --git a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs index 4249506fb..258f2708a 100644 --- a/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs +++ b/tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs @@ -4,6 +4,8 @@ using Dapper; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TechHub.Core.Configuration; using TechHub.Core.Models; using TechHub.Infrastructure.Services.Newsletter; @@ -97,24 +99,83 @@ public async Task AdminTriggerNewsletter_WhenScheduledSendingDisabled_StillProce { await using var arrangeScope = _factory.Services.CreateAsyncScope(); var arrangeConnection = arrangeScope.ServiceProvider.GetRequiredService(); - var roundupSlug = await arrangeConnection.ExecuteScalarAsync( - """ - SELECT slug - FROM content_items - WHERE collection_name = 'roundups' - AND primary_section_name IS NOT NULL - AND primary_section_name <> 'all' - ORDER BY date_epoch DESC - LIMIT 1 - """); + var latestRoundups = await LoadLatestSectionRoundupsAsync(arrangeConnection); + latestRoundups.Should().NotBeEmpty(); + + var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, ResolveRoundupTimeZone()); + await SetLatestRoundupsEpochAsync(arrangeConnection, latestRoundups, ToRoundupEpoch(expectedMonday)); + var roundupTargetKey = expectedMonday.ToString("yyyy-MM-dd"); + + try + { + var countBefore = await arrangeConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey = roundupTargetKey }); + + var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var timedOut = true; + for (var attempt = 0; attempt < 25; attempt++) + { + await Task.Delay(200, TestContext.Current.CancellationToken); + await using var pollScope = _factory.Services.CreateAsyncScope(); + var pollConnection = pollScope.ServiceProvider.GetRequiredService(); + var countAfter = await pollConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey = roundupTargetKey }); + + if (countAfter > countBefore) + { + timedOut = false; + break; + } + } + + timedOut.Should().BeFalse("manual admin trigger should execute even when scheduled sends are disabled"); + } + finally + { + await RestoreLatestRoundupsEpochAsync(arrangeConnection, latestRoundups); + } + } + + [Fact] + public async Task AdminTriggerNewsletter_DailyTriggerProcessesDailyOverviewSend() + { + await using var arrangeScope = _factory.Services.CreateAsyncScope(); + var options = arrangeScope.ServiceProvider.GetRequiredService>().Value; + var timeZone = ResolveTimeZone(options.DailyDigestTimeZoneId); + var localNow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); + var day = DateOnly.FromDateTime(localNow.DateTime.Date.AddDays(-1)); + var dayStartUtc = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - roundupSlug.Should().NotBeNullOrWhiteSpace(); + var arrangeConnection = arrangeScope.ServiceProvider.GetRequiredService(); + await arrangeConnection.ExecuteAsync(""" + DELETE FROM newsletter_subscribers WHERE email = 'daily-trigger@example.com'; + DELETE FROM content_items WHERE collection_name = 'blogs' AND slug = 'daily-trigger-item'; + """); + await arrangeConnection.ExecuteAsync(""" + INSERT INTO newsletter_subscribers (email, is_confirmed, confirmed_at, preferences) + VALUES ('daily-trigger@example.com', TRUE, NOW(), '{"weeklySections":[],"dailySections":["ai"]}'::jsonb) + """); + await arrangeConnection.ExecuteAsync(""" + INSERT INTO content_items + (slug, collection_name, title, content, excerpt, date_epoch, + primary_section_name, external_url, author, feed_name, tags_csv, + sections_bitmask, content_hash, is_ai, created_at) + VALUES + ('daily-trigger-item', 'blogs', 'Daily Trigger Item', 'Body', 'Excerpt', 0, + 'ai', '/ai/all', 'TechHub', 'TechHub', ',AI,', + 1, 'hash-daily-trigger-item', TRUE, @CreatedAt) + ON CONFLICT (collection_name, slug) DO UPDATE SET created_at = EXCLUDED.created_at + """, new { CreatedAt = dayStartUtc.AddHours(12) }); var countBefore = await arrangeConnection.ExecuteScalarAsync( - "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = roundupSlug! }); + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'daily-overview' AND target_key = @TargetKey", + new { TargetKey = day.ToString("yyyy-MM-dd") }); - var response = await _client.PostAsync("/api/admin/newsletter/trigger", null, TestContext.Current.CancellationToken); + var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=daily", null, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Accepted); var timedOut = true; @@ -124,8 +185,8 @@ LIMIT 1 await using var pollScope = _factory.Services.CreateAsyncScope(); var pollConnection = pollScope.ServiceProvider.GetRequiredService(); var countAfter = await pollConnection.ExecuteScalarAsync( - "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", - new { TargetKey = roundupSlug! }); + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'daily-overview' AND target_key = @TargetKey", + new { TargetKey = day.ToString("yyyy-MM-dd") }); if (countAfter > countBefore) { @@ -134,7 +195,54 @@ LIMIT 1 } } - timedOut.Should().BeFalse("manual admin trigger should execute even when scheduled sends are disabled"); + timedOut.Should().BeFalse("manual admin daily trigger should execute daily overview send"); + } + + [Fact] + public async Task AdminTriggerNewsletter_RoundupsNotOnExpectedMonday_SkipsSend() + { + await using var arrangeScope = _factory.Services.CreateAsyncScope(); + var arrangeConnection = arrangeScope.ServiceProvider.GetRequiredService(); + var latestRoundups = await LoadLatestSectionRoundupsAsync(arrangeConnection); + latestRoundups.Should().NotBeEmpty(); + + var expectedMonday = GetExpectedRoundupMonday(DateTimeOffset.UtcNow, ResolveRoundupTimeZone()); + var nonMondayEpoch = ToRoundupEpoch(expectedMonday.AddYears(10).AddDays(1)); + var roundupTargetKey = expectedMonday.ToString("yyyy-MM-dd"); + + await SetLatestRoundupsEpochAsync(arrangeConnection, latestRoundups, nonMondayEpoch); + + try + { + var countBefore = await arrangeConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey = roundupTargetKey }); + + var response = await _client.PostAsync("/api/admin/newsletter/trigger?kind=roundup", null, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var wasSent = false; + for (var attempt = 0; attempt < 25; attempt++) + { + await Task.Delay(200, TestContext.Current.CancellationToken); + await using var pollScope = _factory.Services.CreateAsyncScope(); + var pollConnection = pollScope.ServiceProvider.GetRequiredService(); + var countAfter = await pollConnection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey = roundupTargetKey }); + if (countAfter > countBefore) + { + wasSent = true; + break; + } + } + + wasSent.Should().BeFalse("roundup send should be skipped until latest section roundups match expected Monday"); + } + finally + { + await RestoreLatestRoundupsEpochAsync(arrangeConnection, latestRoundups); + } } [Fact] @@ -267,6 +375,92 @@ private sealed class MessageResponse public string? Message { get; init; } } + private sealed record LatestRoundupRow(string Slug, long DateEpoch); + + private static TimeZoneInfo ResolveTimeZone(string configuredId) + { + if (!string.IsNullOrWhiteSpace(configuredId)) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(configuredId); + } + catch (TimeZoneNotFoundException) + { + } + catch (InvalidTimeZoneException) + { + } + } + + return TimeZoneInfo.Utc; + } + + private static TimeZoneInfo ResolveRoundupTimeZone() + { + return TimeZoneInfo.FindSystemTimeZoneById( + OperatingSystem.IsWindows() ? "Romance Standard Time" : "Europe/Brussels"); + } + + private static DateOnly GetExpectedRoundupMonday(DateTimeOffset utcNow, TimeZoneInfo roundupTimeZone) + { + var localDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(utcNow, roundupTimeZone).Date); + var daysSinceMonday = ((int)localDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; + return localDate.AddDays(-daysSinceMonday); + } + + private static long ToRoundupEpoch(DateOnly publishDate) + { + var roundupTimeZone = ResolveRoundupTimeZone(); + var publishLocal = publishDate.ToDateTime(new TimeOnly(9, 0, 0)); + return (long)TimeZoneInfo.ConvertTimeToUtc(publishLocal, roundupTimeZone) + .Subtract(DateTime.UnixEpoch) + .TotalSeconds; + } + + private static async Task> LoadLatestSectionRoundupsAsync(IDbConnection connection) + { + return (await connection.QueryAsync( + """ + SELECT DISTINCT ON (primary_section_name) + slug AS Slug, + date_epoch AS DateEpoch + FROM content_items + WHERE collection_name = 'roundups' + AND primary_section_name IS NOT NULL + AND primary_section_name <> 'all' + ORDER BY primary_section_name, date_epoch DESC + """)).ToList(); + } + + private static Task SetLatestRoundupsEpochAsync(IDbConnection connection, IEnumerable latestRoundups, long dateEpoch) + { + return connection.ExecuteAsync( + "UPDATE content_items SET date_epoch = @DateEpoch WHERE collection_name = 'roundups' AND slug = ANY(@Slugs)", + new + { + DateEpoch = dateEpoch, + Slugs = latestRoundups.Select(r => r.Slug).ToArray() + }); + } + + private static Task RestoreLatestRoundupsEpochAsync(IDbConnection connection, IEnumerable latestRoundups) + { + return connection.ExecuteAsync( + """ + UPDATE content_items AS c + SET date_epoch = v.date_epoch + FROM (SELECT UNNEST(@Slugs) AS slug, UNNEST(@Epochs) AS date_epoch) AS v + WHERE c.collection_name = 'roundups' + AND c.slug = v.slug + """, + new + { + Slugs = latestRoundups.Select(r => r.Slug).ToArray(), + Epochs = latestRoundups.Select(r => r.DateEpoch).ToArray() + }); + } + [Fact] public async Task ManageRequest_WithValidEmail_ReturnsOk() { diff --git a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs index 155192df3..057218f3b 100644 --- a/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs +++ b/tests/TechHub.Infrastructure.Tests/Services/NewsletterServiceTests.cs @@ -224,16 +224,17 @@ public void IsValidUnsubscribeToken_WithDifferentDecodedLength_ReturnsFalse() public async Task SendCombinedWeeklyAsync_WhenAllSlugsAlreadySent_ReturnsFalse() { const string Slug = "weekly-ai-roundup-2026-06-01-combined-skip"; + const string TargetKey = "2026-06-01"; await SeedRoundupAsync(Slug); await _fixture.Connection.ExecuteAsync(""" INSERT INTO newsletter_send_log (send_kind, target_key, recipient_count, status) - VALUES ('weekly-roundup', @Slug, 0, 'sent') + VALUES ('weekly-roundup', @TargetKey, 0, 'sent') ON CONFLICT (send_kind, target_key) DO UPDATE SET status = 'sent' - """, new { Slug }); + """, new { TargetKey }); var sut = CreateService(); - var sent = await sut.SendCombinedWeeklyAsync([Slug], TestContext.Current.CancellationToken); + var sent = await sut.SendCombinedWeeklyAsync([Slug], TargetKey, TestContext.Current.CancellationToken); sent.Should().BeFalse(); } @@ -243,6 +244,7 @@ public async Task SendCombinedWeeklyAsync_SendsOneEmailPerSubscriberAcrossMultip { const string AiSlug = "weekly-ai-roundup-2026-06-02-combined"; const string DotnetSlug = "weekly-dotnet-roundup-2026-06-02-combined"; + const string TargetKey = "2026-06-02"; // Remove any weekly AI subscribers left by other tests so VerifyNoOtherCalls is reliable await _fixture.Connection.ExecuteAsync(""" @@ -278,7 +280,7 @@ ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO NOTHING var sut = CreateService(emailSender: emailSender.Object); - var sent = await sut.SendCombinedWeeklyAsync([AiSlug, DotnetSlug], TestContext.Current.CancellationToken); + var sent = await sut.SendCombinedWeeklyAsync([AiSlug, DotnetSlug], TargetKey, TestContext.Current.CancellationToken); sent.Should().BeTrue(); @@ -288,16 +290,131 @@ ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO NOTHING Times.Once); emailSender.VerifyNoOtherCalls(); - // Both slugs should be logged as sent - var aiStatus = await _fixture.Connection.ExecuteScalarAsync( - "SELECT status FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @Slug", - new { Slug = AiSlug }); - var dotnetStatus = await _fixture.Connection.ExecuteScalarAsync( - "SELECT status FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @Slug", - new { Slug = DotnetSlug }); + var status = await _fixture.Connection.ExecuteScalarAsync( + "SELECT status FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey }); + var rowCount = await _fixture.Connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key = @TargetKey", + new { TargetKey }); + + status.Should().Be("sent"); + rowCount.Should().Be(1); + } + + [Fact] + public async Task SendCombinedWeeklyAsync_OrdersSectionsByWebsiteOrderInEmail() + { + const string AiSlug = "weekly-ai-roundup-2026-06-03-ordering"; + const string DotnetSlug = "weekly-dotnet-roundup-2026-06-03-ordering"; + + await _fixture.Connection.ExecuteAsync(""" + DELETE FROM newsletter_subscribers + WHERE preferences @> '{"weeklySections":["ai"]}' + OR preferences @> '{"weeklySections":["dotnet"]}' + OR email = 'weekly-ordering@example.com'; + DELETE FROM content_items WHERE collection_name = 'roundups' AND slug IN (@AiSlug, @DotnetSlug); + DELETE FROM newsletter_send_log WHERE send_kind = 'weekly-roundup' AND target_key IN (@AiSlug, @DotnetSlug); + """, new { AiSlug, DotnetSlug }); - aiStatus.Should().Be("sent"); - dotnetStatus.Should().Be("sent"); + await SeedRoundupAsync(AiSlug); + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO content_items + (slug, collection_name, title, content, excerpt, date_epoch, + primary_section_name, external_url, author, feed_name, tags_csv, + sections_bitmask, content_hash, is_ai) + VALUES + (@Slug, 'roundups', '.NET Weekly', '## .NET Highlights', 'Dotnet intro', 1748304000, + 'dotnet', '/dotnet/roundups/' || @Slug, 'TechHub', 'TechHub', ',Roundups,.NET,', + 2, 'hash-newsletter-dotnet-ordering', FALSE) + ON CONFLICT (collection_name, slug) DO NOTHING + """, new { Slug = DotnetSlug }); + + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO newsletter_subscribers (email, is_confirmed, confirmed_at, preferences) + VALUES ('weekly-ordering@example.com', TRUE, NOW(), '{"weeklySections":["dotnet","ai"],"dailySections":[]}'::jsonb) + ON CONFLICT (lower(email)) WHERE unsubscribed_at IS NULL DO UPDATE SET preferences = EXCLUDED.preferences + """); + + string? htmlBody = null; + var emailSender = new Mock(MockBehavior.Strict); + emailSender + .Setup(s => s.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((recipient, _, html, _, _) => + { + if (string.Equals(recipient, "weekly-ordering@example.com", StringComparison.OrdinalIgnoreCase)) + { + htmlBody = html; + } + }) + .ReturnsAsync(true); + + var sut = CreateService(emailSender: emailSender.Object); + var sent = await sut.SendCombinedWeeklyAsync([DotnetSlug, AiSlug], ct: TestContext.Current.CancellationToken); + + sent.Should().BeTrue(); + htmlBody.Should().NotBeNull(); + htmlBody!.IndexOf("Artificial Intelligence", StringComparison.Ordinal).Should().BeLessThan( + htmlBody.IndexOf(".NET", StringComparison.Ordinal), + "weekly digest sections should follow configured website order"); + } + + [Fact] + public async Task SendDailyOverviewAsync_OrdersSectionsByWebsiteOrderInEmail() + { + const string RecipientEmail = "daily-ordering@example.com"; + + await _fixture.Connection.ExecuteAsync(""" + DELETE FROM newsletter_send_log WHERE send_kind = 'daily-overview' AND target_key = '2026-05-22'; + DELETE FROM newsletter_subscribers WHERE email = 'daily-ordering@example.com'; + DELETE FROM content_items WHERE collection_name = 'blogs' AND slug IN ('daily-order-ai-2026-05-22', 'daily-order-dotnet-2026-05-22'); + """); + + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO newsletter_subscribers (email, is_confirmed, confirmed_at, preferences) + VALUES ('daily-ordering@example.com', TRUE, NOW(), '{"weeklySections":[],"dailySections":["dotnet","ai"]}'::jsonb) + """); + + await _fixture.Connection.ExecuteAsync(""" + INSERT INTO content_items + (slug, collection_name, title, content, excerpt, date_epoch, + primary_section_name, external_url, author, feed_name, tags_csv, + sections_bitmask, content_hash, is_ai, created_at) + VALUES + ('daily-order-ai-2026-05-22', 'blogs', 'AI Daily Item', 'Body', 'Excerpt', 1747872000, + 'ai', '/ai/all-daily-order-2026-05-22', 'TechHub', 'TechHub', ',AI,', + 1, 'hash-daily-order-ai', TRUE, '2026-05-22T10:00:00Z'), + ('daily-order-dotnet-2026-05-22', 'blogs', '.NET Daily Item', 'Body', 'Excerpt', 1747872000, + 'dotnet', '/dotnet/all-daily-order-2026-05-22', 'TechHub', 'TechHub', ',.NET,', + 2, 'hash-daily-order-dotnet', FALSE, '2026-05-22T10:00:00Z') + ON CONFLICT (collection_name, slug) DO NOTHING + """); + + string? htmlBody = null; + var emailSender = new Mock(MockBehavior.Strict); + emailSender + .Setup(s => s.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((recipient, _, html, _, _) => + { + if (string.Equals(recipient, RecipientEmail, StringComparison.Ordinal)) + { + htmlBody = html; + } + }) + .ReturnsAsync(true); + + var contentRepository = new Mock(MockBehavior.Strict); + contentRepository + .Setup(x => x.GetAllSectionsAsync(It.IsAny())) + .ReturnsAsync([CreateSection("ai"), CreateSection("dotnet")]); + + var sut = CreateService(contentRepository.Object, emailSender.Object); + var sent = await sut.SendDailyOverviewAsync(new DateOnly(2026, 5, 22), TestContext.Current.CancellationToken); + + sent.Should().BeTrue(); + htmlBody.Should().NotBeNull(); + htmlBody!.IndexOf("Artificial Intelligence", StringComparison.Ordinal).Should().BeLessThan( + htmlBody.IndexOf(".NET", StringComparison.Ordinal), + "daily email sections should follow configured website order"); } private NewsletterService CreateService( @@ -323,7 +440,9 @@ private NewsletterService CreateService( { Sections = new Dictionary { - ["ai"] = new SectionConfig { Title = "Artificial Intelligence", Description = "", Url = "/ai", Tag = "ai", Collections = [] } + ["ai"] = new SectionConfig { Title = "Artificial Intelligence", Description = "", Url = "/ai", Tag = "ai", Order = 1, Collections = [] }, + ["dotnet"] = new SectionConfig { Title = ".NET", Description = "", Url = "/dotnet", Tag = "dotnet", Order = 2, Collections = [] }, + ["azure"] = new SectionConfig { Title = "Azure", Description = "", Url = "/azure", Tag = "azure", Order = 3, Collections = [] } } } }); @@ -339,27 +458,30 @@ private NewsletterService CreateService( } private static Section CreateSection(string name) => + CreateSection(name, name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? ".NET" : "AI"); + + private static Section CreateSection(string name, string title) => new( name, - "AI", - "AI section", - "/ai", - "AI", - [new Collection("blogs", "Blogs", "/ai/blogs", "Blogs", "Blogs")]); + title, + $"{name} section", + $"/{name}", + title, + [new Collection("blogs", "Blogs", $"/{name}/blogs", "Blogs", "Blogs")]); private async Task CleanupDailyOverviewTestDataAsync() { await _fixture.Connection.ExecuteAsync(""" DELETE FROM newsletter_send_log WHERE send_kind = 'daily-overview' - AND target_key IN ('2026-05-20', '2026-05-21'); + AND target_key IN ('2026-05-20', '2026-05-21', '2026-05-22'); DELETE FROM newsletter_subscribers - WHERE email IN ('confirmed@example.com', 'unconfirmed@example.com', 'confirmed-daily@example.com'); + WHERE email IN ('confirmed@example.com', 'unconfirmed@example.com', 'confirmed-daily@example.com', 'daily-ordering@example.com'); DELETE FROM content_items WHERE collection_name = 'blogs' - AND slug IN ('daily-item-newsletter-test-2026-05-20', 'daily-item-newsletter-test-2026-05-21'); + AND slug IN ('daily-item-newsletter-test-2026-05-20', 'daily-item-newsletter-test-2026-05-21', 'daily-order-ai-2026-05-22', 'daily-order-dotnet-2026-05-22'); """); } diff --git a/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs b/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs index f6291d883..4df739123 100644 --- a/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs +++ b/tests/TechHub.Web.Tests/Services/TechHubApiClientTests.cs @@ -145,6 +145,23 @@ await sut.TriggerNewsletterTestSendAsync( handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/admin/newsletter/test-send?email=user%40example.com&kind=weekly§ions=azure§ions=dotnet"); } + [Fact] + public async Task TriggerNewsletterAsync_NormalizesRequestedKind() + { + using var handler = new CapturingHandler(); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://localhost:5003") + }; + + var sut = new TechHubApiClient(httpClient, NullLogger.Instance); + + await sut.TriggerNewsletterAsync("daily\r\n", TestContext.Current.CancellationToken); + + handler.LastRequest.Should().NotBeNull(); + handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/admin/newsletter/trigger?kind=daily"); + } + [Fact] public async Task RequestNewsletterManageLinkAsync_SanitizesQueryValues() {