church-api/src/handlers/bulletins_shared.rs
Benjamin Slingo 4899c3829c Complete major performance optimizations: eliminate N+1 patterns and memory pagination
- Add event categories config endpoint (/api/config/event-categories)
- Fix bulletin processing N+1: batch hymn lookups for single bulletins
- Optimize V2 events list endpoint: replace memory pagination with database LIMIT/OFFSET
- Add missing SQL functions: list_events_paginated() and count_events()
- Preserve HTTP response compatibility for iOS app
2025-09-07 22:33:23 -04:00

297 lines
11 KiB
Rust

// SHARED PROCESSING FUNCTIONS for bulletins handler
use crate::{
error::Result,
models::Bulletin,
services::HymnalService,
};
use regex::Regex;
/// Process multiple bulletins with shared logic (optimized to prevent N+1 queries)
pub async fn process_bulletins_batch(
pool: &sqlx::PgPool,
bulletins: &mut [Bulletin]
) -> Result<()> {
// Collect all hymn numbers across all bulletins first for batch lookup
let mut all_hymn_numbers = std::collections::HashSet::new();
for bulletin in bulletins.iter() {
// Extract hymn numbers from divine_worship content
if let Some(ref worship_content) = bulletin.divine_worship {
let hymn_numbers = extract_hymn_numbers_from_content(worship_content);
all_hymn_numbers.extend(hymn_numbers);
}
// Extract hymn numbers from sabbath_school content
if let Some(ref ss_content) = bulletin.sabbath_school {
let hymn_numbers = extract_hymn_numbers_from_content(ss_content);
all_hymn_numbers.extend(hymn_numbers);
}
}
// Batch query all hymns from both hymnals
let hymn_numbers_vec: Vec<i32> = all_hymn_numbers.into_iter().collect();
let hymnal_codes = vec!["sda-1985".to_string(), "sda-1941".to_string()];
let hymn_map = crate::sql::hymnal::get_hymns_by_numbers_batch(pool, &hymnal_codes, &hymn_numbers_vec).await?;
// Now process each bulletin using the cached hymn data
for bulletin in bulletins.iter_mut() {
process_single_bulletin_with_cache(pool, bulletin, &hymn_map).await?;
}
Ok(())
}
/// Process a single bulletin with all required transformations (optimized to prevent N+1)
pub async fn process_single_bulletin(
pool: &sqlx::PgPool,
bulletin: &mut Bulletin
) -> Result<()> {
// Process scripture reading to include full verse text
bulletin.scripture_reading = process_scripture_reading(pool, &bulletin.scripture_reading).await?;
// Collect all hymn numbers from this bulletin first for batch lookup
let mut all_hymn_numbers = std::collections::HashSet::new();
// Extract hymn numbers from divine_worship content
if let Some(ref worship_content) = bulletin.divine_worship {
let hymn_numbers = extract_hymn_numbers_from_content(worship_content);
all_hymn_numbers.extend(hymn_numbers);
}
// Extract hymn numbers from sabbath_school content
if let Some(ref ss_content) = bulletin.sabbath_school {
let hymn_numbers = extract_hymn_numbers_from_content(ss_content);
all_hymn_numbers.extend(hymn_numbers);
}
// Batch query all hymns from both hymnals for this single bulletin
let hymn_numbers_vec: Vec<i32> = all_hymn_numbers.into_iter().collect();
let hymnal_codes = vec!["sda-1985".to_string(), "sda-1941".to_string()];
let hymn_map = crate::sql::hymnal::get_hymns_by_numbers_batch(pool, &hymnal_codes, &hymn_numbers_vec).await?;
// Process hymn references using cached data
if let Some(ref worship_content) = bulletin.divine_worship {
bulletin.divine_worship = Some(process_hymn_references_with_cache(worship_content, &hymn_map));
}
if let Some(ref ss_content) = bulletin.sabbath_school {
bulletin.sabbath_school = Some(process_hymn_references_with_cache(ss_content, &hymn_map));
}
// Ensure sunset field compatibility
if bulletin.sunset.is_none() {
bulletin.sunset = Some("TBA".to_string());
}
Ok(())
}
/// Process scripture reading field to lookup and include full verse text
async fn process_scripture_reading(
pool: &sqlx::PgPool,
scripture: &Option<String>,
) -> Result<Option<String>> {
let Some(scripture_text) = scripture else {
return Ok(None);
};
// If it already looks like it has full verse text (long), return as-is
if scripture_text.len() > 50 {
return Ok(Some(scripture_text.clone()));
}
// Try to find the verse(s) using direct SQL search
// Allow up to 10 verses for ranges like "Matt 1:21-23"
let verses = sqlx::query_as!(
crate::models::BibleVerse,
"SELECT * FROM bible_verses WHERE is_active = true AND (reference ILIKE $1 OR text ILIKE $1) ORDER BY reference LIMIT $2",
format!("%{}%", scripture_text),
10i64
)
.fetch_all(pool)
.await?;
if !verses.is_empty() {
if verses.len() == 1 {
// Single verse - format as before
let verse = &verses[0];
Ok(Some(format!("{} - {}", verse.text, scripture_text)))
} else {
// Multiple verses - combine them
let combined_text = verses
.iter()
.map(|v| v.text.as_str())
.collect::<Vec<&str>>()
.join(" ");
Ok(Some(format!("{} - {}", combined_text, scripture_text)))
}
} else {
// If no match found, return original text
Ok(Some(scripture_text.clone()))
}
}
/// Process hymn references in bulletin content to include titles
/// Looks for patterns like #319, Hymn 319, No. 319 and adds hymn titles
pub async fn process_hymn_references(
pool: &sqlx::PgPool,
content: &str,
) -> Result<String> {
// Create regex patterns to match hymn references
let hymn_patterns = vec![
// #319
Regex::new(r"#(\d{1,3})").unwrap(),
// Hymn 319 (case insensitive)
Regex::new(r"(?i)hymn\s+(\d{1,3})").unwrap(),
// No. 319
Regex::new(r"(?i)no\.\s*(\d{1,3})").unwrap(),
// Number 319
Regex::new(r"(?i)number\s+(\d{1,3})").unwrap(),
];
let mut result = content.to_string();
// Default to 1985 hymnal (most common)
let default_hymnal = "sda-1985";
// Process each pattern
for pattern in hymn_patterns {
let mut matches_to_replace = Vec::new();
// Find all matches for this pattern
for capture in pattern.captures_iter(&result) {
if let Some(number_match) = capture.get(1) {
if let Ok(hymn_number) = number_match.as_str().parse::<i32>() {
// Try to get hymn from 1985 hymnal first, then 1941
let hymn_title = match HymnalService::get_hymn_by_number(pool, default_hymnal, hymn_number).await {
Ok(Some(hymn)) => Some(hymn.title),
_ => {
// Try 1941 hymnal as fallback
match HymnalService::get_hymn_by_number(pool, "sda-1941", hymn_number).await {
Ok(Some(hymn)) => Some(hymn.title),
_ => None,
}
}
};
if let Some(title) = hymn_title {
let full_match = capture.get(0).unwrap();
matches_to_replace.push((
full_match.start(),
full_match.end(),
format!("{} - {}", full_match.as_str(), title)
));
}
}
}
}
// Apply replacements in reverse order to maintain string indices
matches_to_replace.reverse();
for (start, end, replacement) in matches_to_replace {
result.replace_range(start..end, &replacement);
}
}
Ok(result)
}
/// Extract hymn numbers from content without making database calls
fn extract_hymn_numbers_from_content(content: &str) -> Vec<i32> {
let hymn_patterns = vec![
Regex::new(r"#(\d{1,3})").unwrap(),
Regex::new(r"(?i)hymn\s+(\d{1,3})").unwrap(),
Regex::new(r"(?i)no\.\s*(\d{1,3})").unwrap(),
Regex::new(r"(?i)number\s+(\d{1,3})").unwrap(),
];
let mut hymn_numbers = Vec::new();
for pattern in hymn_patterns {
for capture in pattern.captures_iter(content) {
if let Some(number_match) = capture.get(1) {
if let Ok(hymn_number) = number_match.as_str().parse::<i32>() {
hymn_numbers.push(hymn_number);
}
}
}
}
hymn_numbers
}
/// Process single bulletin using cached hymn data (no database calls for hymns)
async fn process_single_bulletin_with_cache(
pool: &sqlx::PgPool,
bulletin: &mut Bulletin,
hymn_cache: &std::collections::HashMap<(String, i32), crate::models::HymnWithHymnal>
) -> Result<()> {
// Process scripture reading to include full verse text (still needs DB for bible verses)
bulletin.scripture_reading = process_scripture_reading(pool, &bulletin.scripture_reading).await?;
// Process hymn references in worship content using cache
if let Some(ref worship_content) = bulletin.divine_worship {
bulletin.divine_worship = Some(process_hymn_references_with_cache(worship_content, hymn_cache));
}
// Process hymn references in sabbath school content using cache
if let Some(ref ss_content) = bulletin.sabbath_school {
bulletin.sabbath_school = Some(process_hymn_references_with_cache(ss_content, hymn_cache));
}
// Ensure sunset field compatibility
if bulletin.sunset.is_none() {
bulletin.sunset = Some("TBA".to_string());
}
Ok(())
}
/// Process hymn references using cached hymn data (no database calls)
fn process_hymn_references_with_cache(
content: &str,
hymn_cache: &std::collections::HashMap<(String, i32), crate::models::HymnWithHymnal>
) -> String {
let hymn_patterns = vec![
Regex::new(r"#(\d{1,3})").unwrap(),
Regex::new(r"(?i)hymn\s+(\d{1,3})").unwrap(),
Regex::new(r"(?i)no\.\s*(\d{1,3})").unwrap(),
Regex::new(r"(?i)number\s+(\d{1,3})").unwrap(),
];
let mut result = content.to_string();
// Process each pattern
for pattern in hymn_patterns {
let mut matches_to_replace = Vec::new();
// Find all matches for this pattern
for capture in pattern.captures_iter(&result) {
if let Some(number_match) = capture.get(1) {
if let Ok(hymn_number) = number_match.as_str().parse::<i32>() {
// Look for hymn in 1985 hymnal first, then 1941
let hymn_title = hymn_cache.get(&("sda-1985".to_string(), hymn_number))
.or_else(|| hymn_cache.get(&("sda-1941".to_string(), hymn_number)))
.map(|hymn| &hymn.title);
if let Some(title) = hymn_title {
let full_match = capture.get(0).unwrap();
matches_to_replace.push((
full_match.start(),
full_match.end(),
format!("{} - {}", full_match.as_str(), title)
));
}
}
}
}
// Apply replacements in reverse order to maintain string indices
matches_to_replace.reverse();
for (start, end, replacement) in matches_to_replace {
result.replace_range(start..end, &replacement);
}
}
result
}