diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs index 869b346..9ab255a 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs @@ -30,6 +30,11 @@ public static class CacheKeys /// public const string SermonsPattern = "thrive:sermons:*"; + /// + /// Sitemap data containing all series and message IDs + /// + public const string SitemapData = "thrive:sermons:sitemap"; + // ============================================ // Configuration Cache Keys // ============================================ diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapDataResponse.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapDataResponse.cs new file mode 100644 index 0000000..d4f1507 --- /dev/null +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapDataResponse.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace ThriveChurchOfficialAPI.Core +{ + /// + /// Response containing minimal sermon data optimized for sitemap generation + /// + public class SitemapDataResponse + { + /// + /// C'tor + /// + public SitemapDataResponse() + { + Series = new List(); + } + + /// + /// Collection of sermon series with their messages + /// + public List Series { get; set; } + } +} + diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapMessageData.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapMessageData.cs new file mode 100644 index 0000000..86e2e62 --- /dev/null +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapMessageData.cs @@ -0,0 +1,21 @@ +using System; + +namespace ThriveChurchOfficialAPI.Core +{ + /// + /// Minimal message data for sitemap generation + /// + public class SitemapMessageData + { + /// + /// The unique identifier of the message + /// + public string Id { get; set; } + + /// + /// The date this message was given + /// + public DateTime? Date { get; set; } + } +} + diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapSeriesData.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapSeriesData.cs new file mode 100644 index 0000000..64607dc --- /dev/null +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapSeriesData.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace ThriveChurchOfficialAPI.Core +{ + /// + /// Minimal series data for sitemap generation + /// + public class SitemapSeriesData + { + /// + /// C'tor + /// + public SitemapSeriesData() + { + Id = null; + Messages = new List(); + } + + /// + /// The unique identifier of the sermon series + /// + public string Id { get; set; } + + /// + /// The last time this series was updated + /// + public DateTime LastUpdated { get; set; } + + /// + /// Collection of messages in this series + /// + public List Messages { get; set; } + } +} + diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs index aca39c2..74bf18d 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs @@ -170,5 +170,11 @@ public interface ISermonsService /// /// Task UpdatePodcastMessage(string messageId, PodcastMessageRequest request); + + /// + /// Gets minimal sermon data for sitemap generation + /// + /// Series IDs, message IDs, and dates for sitemap URLs + Task> GetSitemapData(); } } \ No newline at end of file diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs index 4b47645..02798bb 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs @@ -1872,5 +1872,61 @@ public Task UpdatePodcastMessage(string messageId, PodcastMessag { return _podcastMessagesRepository.UpdatePodcastMessageById(messageId, request); } + + /// + /// Gets minimal sermon data for sitemap generation + /// + /// Series IDs, message IDs, and dates for sitemap URLs + public async Task> GetSitemapData() + { + // Check cache first + var cacheKey = CacheKeys.SitemapData; + var cachedResponse = _cache.ReadFromCache(cacheKey); + if (cachedResponse != null) + { + return new SystemResponse(cachedResponse, "Success!"); + } + + // Use existing cached service method to get all series summaries + var allSermonsResponse = await GetAllSermons(highResImg: false); + if (allSermonsResponse.HasErrors) + { + return new SystemResponse(true, allSermonsResponse.ErrorMessage); + } + + var allSeries = allSermonsResponse.Result.Summaries; + if (allSeries == null) + { + return new SystemResponse(true, "Failed to retrieve sermon series data"); + } + + // For each series, get the full series data (with messages) from cache + // GetSeriesForId already uses caching at thrive:sermons:series:{seriesId} + var seriesDataTasks = allSeries.Select(s => GetSeriesForId(s.Id)).ToList(); + await Task.WhenAll(seriesDataTasks); + + // Build minimal response from cached data + var response = new SitemapDataResponse + { + Series = seriesDataTasks + .Where(t => !t.Result.HasErrors && t.Result.Result != null) + .Select(t => t.Result.Result) + .Select(series => new SitemapSeriesData + { + Id = series.Id, + LastUpdated = series.LastUpdated ?? DateTime.MinValue, + Messages = series.Messages?.Select(msg => new SitemapMessageData + { + Id = msg.MessageId, + Date = msg.Date + }).ToList() ?? new List() + }).ToList() + }; + + // Cache for 2 hours (matches sitemap ISR revalidation) + _cache.InsertIntoCache(cacheKey, response, TimeSpan.FromHours(2)); + + return new SystemResponse(response, "Success!"); + } } } \ No newline at end of file diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs index bddcb27..25a2e76 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs @@ -1174,6 +1174,349 @@ public async Task GetAllSermons_SeriesWithNoMessages_ReturnsZeroMessageCount() #endregion + #region GetSitemapData Tests + + [TestMethod] + public async Task GetSitemapData_CacheHit_ReturnsCachedData() + { + // Arrange + var cachedData = new SitemapDataResponse + { + Series = new List + { + new SitemapSeriesData + { + Id = "1", + LastUpdated = DateTime.UtcNow, + Messages = new List + { + new SitemapMessageData { Id = "msg1", Date = DateTime.UtcNow } + } + } + } + }; + + _mockCache.Setup(c => c.ReadFromCache(CacheKeys.SitemapData)) + .Returns(cachedData); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + Assert.AreEqual("1", result.Result.Series[0].Id); + + // Verify repository was NOT called (cache hit) + _mockSermonsRepository.Verify(r => r.GetAllSermons(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetSitemapData_CacheMiss_UsesExistingCachedServiceMethods() + { + // Arrange - Mock the flow: GetAllSermons (summary cache) -> GetSeriesForId (series cache) + var series1 = CreateTestSermonSeries("1", "Series 1", "series-1"); + var series2 = CreateTestSermonSeries("2", "Series 2", "series-2"); + var seriesList = new List { series1, series2 }; + + var message1 = CreateTestSermonMessage("msg1", "1", "Message 1"); + var message2 = CreateTestSermonMessage("msg2", "1", "Message 2"); + var message3 = CreateTestSermonMessage("msg3", "2", "Message 3"); + + // Mock GetAllSermons flow (for AllSermonsSummaryResponse) + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(seriesList); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List { message1, message2, message3 }); + + // Mock GetSeriesForId flow (for each series) + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series1, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("2")) + .ReturnsAsync(new SystemResponse(series2, "Success")); + + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message1, message2 }); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("2")) + .ReturnsAsync(new List { message3 }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(2, result.Result.Series.Count); + + var series1Data = result.Result.Series.First(s => s.Id == "1"); + var series2Data = result.Result.Series.First(s => s.Id == "2"); + Assert.AreEqual(2, series1Data.Messages.Count); + Assert.AreEqual(1, series2Data.Messages.Count); + + // Verify sitemap cache was updated + _mockCache.Verify(c => c.InsertIntoCache( + CacheKeys.SitemapData, + It.IsAny(), + TimeSpan.FromHours(2)), Times.Once); + } + + [TestMethod] + public async Task GetSitemapData_NoSeries_ReturnsEmptyList() + { + // Arrange + var emptySeries = new List(); + var emptyMessages = new List(); + + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(emptySeries); + + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(emptyMessages); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.IsNotNull(result.Result.Series); + Assert.AreEqual(0, result.Result.Series.Count); + } + + [TestMethod] + public async Task GetSitemapData_SeriesWithNoMessages_ReturnsEmptyMessagesList() + { + // Arrange + var series = CreateTestSermonSeries("1", "Empty Series", "empty-series"); + var seriesList = new List { series }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(seriesList); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List()); + + // Mock GetSeriesForId flow (series with no messages) + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List()); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + Assert.AreEqual(0, result.Result.Series[0].Messages.Count); + } + + [TestMethod] + public async Task GetSitemapData_GetAllSermonsCacheHit_UsesCachedData() + { + // Arrange - AllSermonsSummaryResponse is already in cache + var cachedSummary = new AllSermonsSummaryResponse + { + Summaries = new List + { + new AllSermonSeriesSummary { Id = "1", Title = "Series 1", LastUpdated = DateTime.UtcNow } + } + }; + var series = CreateTestSermonSeries("1", "Series 1", "series-1"); + var message = CreateTestSermonMessage("msg1", "1", "Message 1"); + + // GetAllSermons will find its cache hit + var sermonsSummaryCacheKey = CacheKeys.Format(CacheKeys.SermonsSummary, false); + _mockCache.Setup(c => c.ReadFromCache(sermonsSummaryCacheKey)) + .Returns(cachedSummary); + + // GetSeriesForId will still be called (different cache) + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + + // Verify repository GetAllSermons was NOT called (cache hit for summary) + _mockSermonsRepository.Verify(r => r.GetAllSermons(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetSitemapData_GetSeriesForIdFails_SkipsFailedSeries() + { + // Arrange - One series succeeds, one fails + var series1 = CreateTestSermonSeries("1", "Series 1", "series-1"); + var series2 = CreateTestSermonSeries("2", "Series 2", "series-2"); + var message1 = CreateTestSermonMessage("msg1", "1", "Message 1"); + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series1, series2 }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List { message1 }); + + // Mock GetSeriesForId - series1 succeeds, series2 fails + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series1, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("2")) + .ReturnsAsync(new SystemResponse(true, "Series not found")); + + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message1 }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert - Only series1 should be in the result (series2 was skipped due to error) + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + Assert.AreEqual("1", result.Result.Series[0].Id); + } + + [TestMethod] + public async Task GetSitemapData_ReturnsCorrectSeriesData() + { + // Arrange + var lastUpdated = new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var series = new SermonSeries + { + Id = "test-series-id", + Name = "Test Series", + Slug = "test-series", + StartDate = DateTime.UtcNow, + LastUpdated = lastUpdated + }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List()); + + // Mock GetSeriesForId flow + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("test-series-id")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("test-series-id")) + .ReturnsAsync(new List()); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + var seriesData = result.Result.Series[0]; + Assert.AreEqual("test-series-id", seriesData.Id); + Assert.AreEqual(lastUpdated, seriesData.LastUpdated); + } + + [TestMethod] + public async Task GetSitemapData_ReturnsCorrectMessageData() + { + // Arrange + var messageDate = new DateTime(2026, 1, 20, 9, 0, 0, DateTimeKind.Utc); + var series = CreateTestSermonSeries("1", "Test Series", "test-series"); + var message = new SermonMessage + { + Id = "test-message-id", + SeriesId = "1", + Title = "Test Message", + Speaker = "Test Speaker", + Date = messageDate, + AudioDuration = 1800 + }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List { message }); + + // Mock GetSeriesForId flow + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + var messageData = result.Result.Series[0].Messages[0]; + Assert.AreEqual("test-message-id", messageData.Id); + Assert.AreEqual(messageDate, messageData.Date); + } + + [TestMethod] + public async Task GetSitemapData_LeveragesExistingSeriesCaches() + { + // Arrange - This test verifies GetSeriesForId is called for each series (which uses caching) + var series1 = CreateTestSermonSeries("series-1", "Series 1", "series-1"); + var series2 = CreateTestSermonSeries("series-2", "Series 2", "series-2"); + var series3 = CreateTestSermonSeries("series-3", "Series 3", "series-3"); + + var messages = new List + { + CreateTestSermonMessage("msg1", "series-1", "Message 1"), + CreateTestSermonMessage("msg2", "series-1", "Message 2"), + CreateTestSermonMessage("msg3", "series-2", "Message 3"), + CreateTestSermonMessage("msg4", "series-2", "Message 4"), + CreateTestSermonMessage("msg5", "series-2", "Message 5") + // series-3 has no messages + }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series1, series2, series3 }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(messages); + + // Mock GetSeriesForId flow for each series + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("series-1")) + .ReturnsAsync(new SystemResponse(series1, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("series-2")) + .ReturnsAsync(new SystemResponse(series2, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("series-3")) + .ReturnsAsync(new SystemResponse(series3, "Success")); + + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("series-1")) + .ReturnsAsync(messages.Where(m => m.SeriesId == "series-1").ToList()); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("series-2")) + .ReturnsAsync(messages.Where(m => m.SeriesId == "series-2").ToList()); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("series-3")) + .ReturnsAsync(new List()); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(3, result.Result.Series.Count); + + var series1Data = result.Result.Series.First(s => s.Id == "series-1"); + var series2Data = result.Result.Series.First(s => s.Id == "series-2"); + var series3Data = result.Result.Series.First(s => s.Id == "series-3"); + + Assert.AreEqual(2, series1Data.Messages.Count); + Assert.AreEqual(3, series2Data.Messages.Count); + Assert.AreEqual(0, series3Data.Messages.Count); + } + + #endregion + #region Helper Methods private SermonSeries CreateTestSermonSeries(string id, string name, string slug) diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs index 9ee23ef..7a9bc55 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs @@ -729,5 +729,32 @@ public async Task> ImportSermonData([From return Ok(response.Result); } + + /// + /// Get minimal sermon data for sitemap generation + /// + /// + /// Returns all sermon series and their messages with only IDs and dates. + /// Designed for efficient sitemap generation without requiring multiple API calls. + /// Response is cached for 2 hours. + /// + /// Series and message IDs with dates + /// OK - Sitemap data retrieved + /// Bad Request - Failed to retrieve data + [Produces("application/json")] + [HttpGet("sitemap")] + [ProducesResponseType(typeof(SitemapDataResponse), 200)] + [ProducesResponseType(400)] + public async Task> GetSitemapData() + { + var response = await _sermonsService.GetSitemapData(); + + if (response.HasErrors) + { + return StatusCode(400, response.ErrorMessage); + } + + return response.Result; + } } } \ No newline at end of file