Optimize N+1 queries and fix missing event update endpoint

Performance optimizations:
- Fix media items N+1: batch scripture reading lookups (1+N → 2 queries)
- Fix hymnal thematic lists N+1: batch ambits queries (1+N → 2 queries)
- Add batch query functions for bulletins and hymnal ambits

Bug fix:
- Add missing PUT /api/admin/events/:id endpoint for event updates
- Connect existing EventsV1Service::update method to web layer

Database performance significantly improved for high-traffic endpoints
used by website, iOS app, and LuminaLP integrations.
This commit is contained in:
Benjamin Slingo 2025-09-07 21:55:49 -04:00
parent 73c1b416ee
commit 8728e14125
6 changed files with 113 additions and 15 deletions

View file

@ -22,7 +22,7 @@ use tokio::fs;
use crate::{
services::{EventsV1Service, PendingEventsService},
error::Result,
models::{Event, PendingEvent, ApiResponse, PaginatedResponse},
models::{Event, PendingEvent, ApiResponse, PaginatedResponse, UpdateEventRequest},
AppState,
};
@ -218,3 +218,14 @@ pub async fn delete_pending(
PendingEventsService::delete(&state.pool, &id).await?;
Ok(success_with_message("Pending event deleted successfully".to_string(), "Pending event deleted successfully"))
}
/// Update an existing event (admin only)
pub async fn update(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(request): Json<UpdateEventRequest>,
) -> Result<Json<ApiResponse<Event>>> {
let updated_event = EventsV1Service::update(&state.pool, &id, request).await?;
Ok(success_with_message(updated_event, "Event updated successfully"))
}

View file

@ -124,11 +124,26 @@ pub async fn list_sermons(
.await
.map_err(|e| crate::error::ApiError::Database(e.to_string()))?;
// Link sermons to bulletins for scripture readings using shared SQL
for item in &mut media_items {
if item.scripture_reading.is_none() && item.date.is_some() {
if let Ok(Some(bulletin_data)) = sql::bulletins::get_by_date_for_scripture(&state.pool, item.date.unwrap()).await {
item.scripture_reading = bulletin_data.scripture_reading;
// Link sermons to bulletins for scripture readings using optimized batch query
let dates_needing_scripture: Vec<chrono::NaiveDate> = media_items
.iter()
.filter_map(|item| {
if item.scripture_reading.is_none() && item.date.is_some() {
Some(item.date.unwrap())
} else {
None
}
})
.collect();
if !dates_needing_scripture.is_empty() {
if let Ok(scripture_map) = sql::bulletins::get_scripture_readings_batch(&state.pool, &dates_needing_scripture).await {
for item in &mut media_items {
if item.scripture_reading.is_none() && item.date.is_some() {
if let Some(scripture) = scripture_map.get(&item.date.unwrap()) {
item.scripture_reading = scripture.clone();
}
}
}
}
}

View file

@ -86,6 +86,7 @@ async fn main() -> Result<()> {
.route("/events/pending/:id/approve", post(handlers::events::approve))
.route("/events/pending/:id/reject", post(handlers::events::reject))
.route("/events/pending/:id", delete(handlers::events::delete_pending))
.route("/events/:id", put(handlers::events::update))
.route("/events/:id", delete(handlers::events::delete))
.route("/config", get(handlers::config::get_admin_config))
.route("/config", put(handlers::config::update_config))

View file

@ -155,21 +155,26 @@ impl HymnalService {
// Thematic list operations
pub async fn list_thematic_lists(pool: &PgPool, hymnal_id: Uuid) -> Result<Vec<ThematicListWithAmbits>> {
let lists = hymnal::get_thematic_lists(pool, &hymnal_id).await?;
let mut result = Vec::new();
for list in lists {
let ambits = hymnal::get_thematic_ambits(pool, &list.id).await?;
result.push(ThematicListWithAmbits {
// Extract all list IDs for batch query
let list_ids: Vec<uuid::Uuid> = lists.iter().map(|list| list.id).collect();
// Get all ambits in single query
let ambits_map = hymnal::get_thematic_ambits_batch(pool, &list_ids).await?;
// Build results using the batch data
let result = lists
.into_iter()
.map(|list| ThematicListWithAmbits {
id: list.id,
hymnal_id: list.hymnal_id,
name: list.name,
sort_order: list.sort_order,
ambits,
ambits: ambits_map.get(&list.id).cloned().unwrap_or_default(),
created_at: list.created_at,
updated_at: list.updated_at,
});
}
})
.collect();
Ok(result)
}

View file

@ -67,6 +67,37 @@ pub async fn get_by_date_for_scripture(pool: &PgPool, date: chrono::NaiveDate) -
Ok(bulletin)
}
/// Get scripture readings for multiple dates in single query (optimization for media items)
pub async fn get_scripture_readings_batch(pool: &PgPool, dates: &[chrono::NaiveDate]) -> Result<std::collections::HashMap<chrono::NaiveDate, Option<String>>> {
if dates.is_empty() {
return Ok(std::collections::HashMap::new());
}
let results = sqlx::query!(
r#"SELECT DISTINCT ON (date) date, scripture_reading
FROM bulletins
WHERE date = ANY($1) AND is_active = true
ORDER BY date, created_at DESC"#,
dates
)
.fetch_all(pool)
.await?;
let mut scripture_map = std::collections::HashMap::new();
// Initialize all dates with None
for date in dates {
scripture_map.insert(*date, None);
}
// Fill in the scripture readings we found
for row in results {
scripture_map.insert(row.date, row.scripture_reading);
}
Ok(scripture_map)
}
/// Get current bulletin (raw SQL, no conversion)
pub async fn get_current(pool: &PgPool) -> Result<Option<Bulletin>> {
sqlx::query_as!(

View file

@ -445,6 +445,41 @@ pub async fn get_thematic_ambits(pool: &PgPool, list_id: &Uuid) -> Result<Vec<cr
.map_err(|e| crate::error::ApiError::DatabaseError(e))
}
/// Get thematic ambits for multiple lists in single query (optimization)
pub async fn get_thematic_ambits_batch(pool: &PgPool, list_ids: &[uuid::Uuid]) -> Result<std::collections::HashMap<uuid::Uuid, Vec<crate::models::ThematicAmbit>>> {
if list_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let ambits = sqlx::query_as!(
crate::models::ThematicAmbit,
r#"
SELECT id, thematic_list_id, name, start_number, end_number, sort_order, created_at, updated_at
FROM thematic_ambits
WHERE thematic_list_id = ANY($1)
ORDER BY thematic_list_id, sort_order, name
"#,
list_ids
)
.fetch_all(pool)
.await
.map_err(|e| crate::error::ApiError::DatabaseError(e))?;
let mut ambits_map: std::collections::HashMap<uuid::Uuid, Vec<crate::models::ThematicAmbit>> = std::collections::HashMap::new();
// Initialize all list_ids with empty vectors
for list_id in list_ids {
ambits_map.insert(*list_id, Vec::new());
}
// Group ambits by their list_id
for ambit in ambits {
ambits_map.entry(ambit.thematic_list_id).or_insert_with(Vec::new).push(ambit);
}
Ok(ambits_map)
}
/// Count responsive readings
pub async fn count_responsive_readings(pool: &PgPool) -> Result<i64> {
let count = sqlx::query!("SELECT COUNT(*) as count FROM responsive_readings")