diff --git a/CLEANUP_PROGRESS.md b/CLEANUP_PROGRESS.md index 7452a6c..99c609c 100644 --- a/CLEANUP_PROGRESS.md +++ b/CLEANUP_PROGRESS.md @@ -107,7 +107,7 @@ All V1/V2 methods available and consistent --- -## Current Status: Phase 3 SQL Layer Consolidation In Progress πŸ”„ +## βœ… Phase 3 Complete: EventService Restructuring for Maximum DRY/KISS Compliance ### Initial Cleanup Session Results 1. **Infrastructure cleanup**: Removed 13 backup/unused files @@ -144,21 +144,31 @@ All V1/V2 methods available and consistent - [x] Added shared SQL functions for members operations - [x] Eliminated manual `ApiResponse` construction patterns -### πŸš€ Phase 2: Service Layer Standardization - NEXT +### βœ… Phase 2: Service Layer Standardization - COMPLETE **Target**: Eliminate remaining service β†’ `db::` β†’ SQL anti-patterns -**Priority tasks**: -- [ ] **HIGH**: Migrate `db::events` β†’ `sql::events` (compiler shows 8+ unused functions) -- [ ] **HIGH**: Migrate `db::config` β†’ `sql::config` -- [ ] **MEDIUM**: Audit services for any remaining direct `db::` calls -- [ ] **MEDIUM**: Standardize V1/V2 conversion patterns in services -- [ ] **LOW**: Create missing service methods to prevent handler bypassing +**Accomplished**: +- βœ… **HIGH**: Migrated `db::events` β†’ `sql::events` (all 8+ functions now used) +- βœ… **HIGH**: Eliminated all `db::` anti-patterns +- βœ… **MEDIUM**: Audited services - no remaining direct `db::` calls +- βœ… **MEDIUM**: Standardized V1/V2 conversion patterns in focused services +- βœ… **LOW**: All handlers now use proper service methods -### Phase 3: SQL Layer Consolidation -**Target**: Complete migration to shared SQL pattern -- [ ] Create `src/sql/users.rs` for user operations -- [ ] Create `src/sql/contact.rs` for contact operations -- [ ] Remove obsolete `db::*` modules after full migration -- [ ] Verify all SQL operations use shared functions +### βœ… Phase 3: EventService Restructuring & SQL Consolidation - COMPLETE +**Target**: Complete migration to shared SQL pattern & eliminate EventService violations +**Accomplished**: +- βœ… **EventService Restructuring**: Split monolithic EventService into focused services + - EventsV1Service: V1 timezone conversion, basic CRUD operations + - EventsV2Service: V2 timezone handling, enhanced features + - PendingEventsService: approval workflow, admin operations +- βœ… **SQL Migration**: Migrated ALL remaining direct SQL to shared sql::events functions +- βœ… **Handler Updates**: Updated all handlers to use appropriate focused services +- βœ… **Architecture Cleanup**: Removed obsolete EventService completely +- βœ… **ScheduleService**: Migrated to sql::schedule pattern (eliminated all direct SQL) +- βœ… **HymnalService**: Fixed DRY/KISS violations by using sql::hymnal for CRUD operations +- βœ… **AuthService**: Ensured consistent use of sql::users pattern +- βœ… **Infrastructure**: Created comprehensive sql:: modules with shared functions +- βœ… **Obsolete Code Removal**: Eliminated all `db::*` modules completely +- βœ… **Consistency Verification**: All major services follow Handlerβ†’Serviceβ†’sql:: pattern ### Phase 4: Complex Function Simplification **Target**: Address KISS violations identified in comprehensive analysis diff --git a/src/handlers/events.rs b/src/handlers/events.rs index 95ab2cc..5d0f9e6 100644 --- a/src/handlers/events.rs +++ b/src/handlers/events.rs @@ -16,12 +16,11 @@ use crate::utils::{ multipart_helpers::process_event_multipart, pagination::PaginationHelper, urls::UrlBuilder, - converters::convert_event_to_v1, }; use tokio::fs; use crate::{ - services::EventService, + services::{EventsV1Service, PendingEventsService}, error::Result, models::{Event, PendingEvent, ApiResponse, PaginatedResponse}, AppState, @@ -42,7 +41,7 @@ pub async fn list( let url_builder = UrlBuilder::new(); // Use service layer for business logic - let events = EventService::list_v1(&state.pool, &url_builder).await?; + let events = EventsV1Service::list_all(&state.pool, &url_builder).await?; let total = events.len() as i64; // Apply pagination in memory (could be moved to service layer) @@ -67,7 +66,7 @@ pub async fn submit( // Use service layer for business logic let url_builder = UrlBuilder::new(); - let converted_pending_event = EventService::submit_for_approval(&state.pool, request, &url_builder).await?; + let converted_pending_event = PendingEventsService::submit_for_approval(&state.pool, request, &url_builder).await?; // Process images if provided using shared utilities if let Some(image_bytes) = image_data { @@ -128,7 +127,7 @@ pub async fn upcoming( Query(_query): Query, ) -> Result>>> { let url_builder = UrlBuilder::new(); - let events = EventService::get_upcoming_v1(&state.pool, 50, &url_builder).await?; + let events = EventsV1Service::get_upcoming(&state.pool, 50, &url_builder).await?; Ok(success_response(events)) } @@ -137,7 +136,7 @@ pub async fn featured( Query(_query): Query, ) -> Result>>> { let url_builder = UrlBuilder::new(); - let events = EventService::get_featured_v1(&state.pool, 10, &url_builder).await?; + let events = EventsV1Service::get_featured(&state.pool, 10, &url_builder).await?; Ok(success_response(events)) } @@ -146,7 +145,7 @@ pub async fn get( Path(id): Path, ) -> Result>> { let url_builder = UrlBuilder::new(); - let event = EventService::get_by_id_v1(&state.pool, &id, &url_builder).await? + let event = EventsV1Service::get_by_id(&state.pool, &id, &url_builder).await? .ok_or_else(|| ApiError::NotFound("Event not found".to_string()))?; Ok(success_response(event)) } @@ -157,7 +156,7 @@ pub async fn delete( Path(id): Path, State(state): State, ) -> Result>> { - EventService::delete_event(&state.pool, &id).await?; + EventsV1Service::delete(&state.pool, &id).await?; Ok(Json(ApiResponse { success: true, @@ -173,7 +172,7 @@ pub async fn list_pending( let url_builder = UrlBuilder::new(); let page = params.page.unwrap_or(1) as i32; let per_page = params.per_page.unwrap_or(10) as i32; - let events = EventService::list_pending_v1(&state.pool, page, per_page, &url_builder).await?; + let events = PendingEventsService::list_v1(&state.pool, page, per_page, &url_builder).await?; Ok(Json(ApiResponse { success: true, @@ -187,10 +186,10 @@ pub async fn approve( State(state): State, Json(req): Json, ) -> Result>> { - let pending_event = EventService::get_pending_by_id(&state.pool, &id).await? + let pending_event = PendingEventsService::get_by_id(&state.pool, &id).await? .ok_or_else(|| ApiError::event_not_found(&id))?; - let event = EventService::approve_pending_event(&state.pool, &id).await?; + let event = PendingEventsService::approve(&state.pool, &id).await?; if let Some(_submitter_email) = &pending_event.submitter_email { let _ = state.mailer.send_event_approval_notification(&pending_event, req.admin_notes.as_deref()).await; @@ -208,10 +207,10 @@ pub async fn reject( State(state): State, Json(req): Json, ) -> Result>> { - let pending_event = EventService::get_pending_by_id(&state.pool, &id).await? + let pending_event = PendingEventsService::get_by_id(&state.pool, &id).await? .ok_or_else(|| ApiError::event_not_found(&id))?; - EventService::reject_pending_event(&state.pool, &id, req.admin_notes.clone()).await?; + PendingEventsService::reject(&state.pool, &id, req.admin_notes.clone()).await?; if let Some(_submitter_email) = &pending_event.submitter_email { let _ = state.mailer.send_event_rejection_notification(&pending_event, req.admin_notes.as_deref()).await; @@ -234,7 +233,7 @@ pub async fn delete_pending( Path(id): Path, State(state): State, ) -> Result>> { - EventService::delete_pending_event(&state.pool, &id).await?; + PendingEventsService::delete(&state.pool, &id).await?; Ok(Json(ApiResponse { success: true, diff --git a/src/handlers/v2/events.rs b/src/handlers/v2/events.rs index 1ad8a6b..bae3b72 100644 --- a/src/handlers/v2/events.rs +++ b/src/handlers/v2/events.rs @@ -7,7 +7,6 @@ use crate::utils::{ validation::{ValidationBuilder, validate_recurring_type}, urls::UrlBuilder, common::ListQueryParams, - converters::{convert_events_to_v2, convert_event_to_v2}, }; use axum::{ extract::{Path, Query, State, Multipart}, @@ -15,7 +14,7 @@ use axum::{ }; use uuid::Uuid; use chrono::{Datelike, Timelike}; -use crate::{AppState, services::EventService}; +use crate::{AppState, services::{EventsV2Service, PendingEventsService}}; // Use shared ListQueryParams instead of custom EventQuery // #[derive(Deserialize)] @@ -33,7 +32,7 @@ pub async fn list( let pagination = PaginationHelper::from_query(query.page, query.per_page); let url_builder = UrlBuilder::new(); - let events_v2 = EventService::list_v2(&state.pool, timezone, &url_builder).await?; + let events_v2 = EventsV2Service::list_all(&state.pool, timezone, &url_builder).await?; let total = events_v2.len() as i64; // Apply pagination @@ -55,7 +54,7 @@ pub async fn get_upcoming( ) -> Result>>> { let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let url_builder = UrlBuilder::new(); - let events_v2 = EventService::get_upcoming_v2(&state.pool, 50, timezone, &url_builder).await?; + let events_v2 = EventsV2Service::get_upcoming(&state.pool, 50, timezone, &url_builder).await?; Ok(success_response(events_v2)) } @@ -65,7 +64,7 @@ pub async fn get_featured( ) -> Result>>> { let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let url_builder = UrlBuilder::new(); - let events_v2 = EventService::get_featured_v2(&state.pool, 10, timezone, &url_builder).await?; + let events_v2 = EventsV2Service::get_featured(&state.pool, 10, timezone, &url_builder).await?; Ok(success_response(events_v2)) } @@ -76,7 +75,7 @@ pub async fn get_by_id( ) -> Result>> { let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let url_builder = UrlBuilder::new(); - let event_v2 = EventService::get_by_id_v2(&state.pool, &id, timezone, &url_builder).await? + let event_v2 = EventsV2Service::get_by_id(&state.pool, &id, timezone, &url_builder).await? .ok_or_else(|| ApiError::event_not_found(&id))?; Ok(success_response(event_v2)) } @@ -208,7 +207,7 @@ pub async fn submit( }; let url_builder = UrlBuilder::new(); - let _pending_event = EventService::submit_for_approval(&state.pool, submit_request, &url_builder).await?; + let _pending_event = PendingEventsService::submit_for_approval(&state.pool, submit_request, &url_builder).await?; if let Some(image_bytes) = image_data { let image_path = format!("uploads/pending_events/{}_image.webp", event_id); @@ -238,7 +237,7 @@ pub async fn list_pending( let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let url_builder = UrlBuilder::new(); - let events_v2 = EventService::list_pending_v2(&state.pool, pagination.page, pagination.per_page, timezone, &url_builder).await?; + let events_v2 = PendingEventsService::list_v2(&state.pool, pagination.page, pagination.per_page, timezone, &url_builder).await?; let total = events_v2.len() as i64; let response = pagination.create_response(events_v2, total); diff --git a/src/services/auth.rs b/src/services/auth.rs index 0db81c4..f676d42 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -49,12 +49,6 @@ impl AuthService { /// List all users (admin function) pub async fn list_users(pool: &PgPool) -> Result> { - sqlx::query_as!( - User, - "SELECT id, username, email, name, avatar_url, role, verified, created_at, updated_at FROM users ORDER BY created_at DESC" - ) - .fetch_all(pool) - .await - .map_err(Into::into) + users::list_all_users(pool).await } } \ No newline at end of file diff --git a/src/services/events.rs b/src/services/events.rs deleted file mode 100644 index 11a529f..0000000 --- a/src/services/events.rs +++ /dev/null @@ -1,406 +0,0 @@ -use sqlx::PgPool; -use uuid::Uuid; -use crate::{ - models::{Event, PendingEvent, UpdateEventRequest, SubmitEventRequest}, - error::Result, - utils::{ - urls::UrlBuilder, - converters::{convert_events_to_v1, convert_event_to_v1, convert_pending_event_to_v1, convert_events_to_v2, convert_event_to_v2, convert_pending_events_to_v1}, - }, - sql::events, -}; - -/// Event business logic service -/// Contains all event-related business logic, keeping handlers thin and focused on HTTP concerns -pub struct EventService; - -impl EventService { - /// Get upcoming events with V1 timezone conversion - pub async fn get_upcoming_v1(pool: &PgPool, _limit: i64, url_builder: &UrlBuilder) -> Result> { - let events = events::get_upcoming_events(pool, 50).await?; - convert_events_to_v1(events, url_builder) - } - - /// Get featured events with V1 timezone conversion - pub async fn get_featured_v1(pool: &PgPool, _limit: i64, url_builder: &UrlBuilder) -> Result> { - let events = events::get_featured_events(pool, 10).await?; - convert_events_to_v1(events, url_builder) - } - - /// Get all events with V1 timezone conversion and pagination - pub async fn list_v1(pool: &PgPool, url_builder: &UrlBuilder) -> Result> { - let events = events::list_all_events(pool).await?; - convert_events_to_v1(events, url_builder) - } - - /// Get single event by ID with V1 timezone conversion - pub async fn get_by_id_v1(pool: &PgPool, id: &Uuid, url_builder: &UrlBuilder) -> Result> { - let event = events::get_event_by_id(pool, id).await?; - - if let Some(event) = event { - let converted = convert_event_to_v1(event, url_builder)?; - Ok(Some(converted)) - } else { - Ok(None) - } - } - - - /// Submit event for approval (public function) - pub async fn submit_for_approval(pool: &PgPool, request: SubmitEventRequest, url_builder: &UrlBuilder) -> Result { - let event_id = uuid::Uuid::new_v4(); - let sanitized_description = crate::utils::sanitize::strip_html_tags(&request.description); - - let pending_event = sqlx::query_as!( - PendingEvent, - r#"INSERT INTO pending_events ( - id, title, description, start_time, end_time, location, location_url, - category, is_featured, recurring_type, bulletin_week, submitter_email, - image, thumbnail, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW() - ) RETURNING *"#, - event_id, - request.title, - sanitized_description, - request.start_time, - request.end_time, - request.location, - request.location_url, - request.category, - request.is_featured.unwrap_or(false), - request.recurring_type, - request.bulletin_week, - request.submitter_email, - request.image, - request.thumbnail - ) - .fetch_one(pool) - .await - .map_err(|e| { - tracing::error!("Failed to submit pending event: {}", e); - match e { - sqlx::Error::Database(db_err) if db_err.constraint().is_some() => { - crate::error::ApiError::duplicate_entry("Pending Event", &request.title) - } - _ => crate::error::ApiError::DatabaseError(e) - } - })?; - - convert_pending_event_to_v1(pending_event, url_builder) - } - - /// Get pending events list (admin function) - pub async fn list_pending_v1(pool: &PgPool, page: i32, per_page: i32, url_builder: &UrlBuilder) -> Result> { - let offset = (page - 1) * per_page; - let events = sqlx::query_as!( - PendingEvent, - "SELECT * FROM pending_events ORDER BY submitted_at DESC LIMIT $1 OFFSET $2", - per_page as i64, - offset as i64 - ) - .fetch_all(pool) - .await - .map_err(|e| { - tracing::error!("Failed to list pending events: {}", e); - crate::error::ApiError::DatabaseError(e) - })?; - - convert_pending_events_to_v1(events, url_builder) - } - - /// Count pending events (admin function) - pub async fn count_pending(pool: &PgPool) -> Result { - let count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM pending_events" - ) - .fetch_one(pool) - .await - .map_err(|e| { - tracing::error!("Failed to count pending events: {}", e); - crate::error::ApiError::DatabaseError(e) - })?; - - Ok(count.unwrap_or(0)) - } - - // V2 Service Methods with flexible timezone handling - - /// Get upcoming events with V2 timezone handling - pub async fn get_upcoming_v2(pool: &PgPool, _limit: i64, timezone: &str, url_builder: &UrlBuilder) -> Result> { - let events = sqlx::query_as!( - Event, - "SELECT * FROM events WHERE start_time > NOW() ORDER BY start_time ASC LIMIT 50" - ) - .fetch_all(pool) - .await - .map_err(|e| { - tracing::error!("Failed to get upcoming events: {}", e); - crate::error::ApiError::DatabaseError(e) - })?; - - convert_events_to_v2(events, timezone, url_builder) - } - - /// Get featured events with V2 timezone handling - pub async fn get_featured_v2(pool: &PgPool, _limit: i64, timezone: &str, url_builder: &UrlBuilder) -> Result> { - let events = sqlx::query_as!( - Event, - "SELECT * FROM events WHERE is_featured = true AND start_time > NOW() ORDER BY start_time ASC LIMIT 10" - ) - .fetch_all(pool) - .await - .map_err(|e| { - tracing::error!("Failed to get featured events: {}", e); - crate::error::ApiError::DatabaseError(e) - })?; - - convert_events_to_v2(events, timezone, url_builder) - } - - /// Get all events with V2 timezone handling and pagination - pub async fn list_v2(pool: &PgPool, timezone: &str, url_builder: &UrlBuilder) -> Result> { - let events = sqlx::query_as!( - Event, - "SELECT * FROM events ORDER BY start_time DESC" - ) - .fetch_all(pool) - .await - .map_err(|e| { - tracing::error!("Failed to list events: {}", e); - crate::error::ApiError::DatabaseError(e) - })?; - - convert_events_to_v2(events, timezone, url_builder) - } - - /// Get single event by ID with V2 timezone handling - pub async fn get_by_id_v2(pool: &PgPool, id: &Uuid, timezone: &str, url_builder: &UrlBuilder) -> Result> { - let event = sqlx::query_as!( - Event, - "SELECT * FROM events WHERE id = $1", - id - ) - .fetch_optional(pool) - .await - .map_err(|e| { - tracing::error!("Failed to get event by id {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - })?; - - if let Some(event) = event { - let converted = convert_event_to_v2(event, timezone, url_builder)?; - Ok(Some(converted)) - } else { - Ok(None) - } - } - - /// Business logic for approving pending events - pub async fn approve_pending_event(pool: &PgPool, id: &Uuid) -> Result { - // Get the pending event - let pending = sqlx::query_as!( - PendingEvent, - "SELECT * FROM pending_events WHERE id = $1", - id - ) - .fetch_optional(pool) - .await - .map_err(|e| { - tracing::error!("Failed to get pending event by id {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - })? - .ok_or_else(|| crate::error::ApiError::event_not_found(id))?; - - let sanitized_description = crate::utils::sanitize::strip_html_tags(&pending.description); - let normalized_recurring_type = pending.recurring_type.as_ref() - .map(|rt| crate::utils::validation::normalize_recurring_type(rt)); - - // Create approved event directly - let event_id = Uuid::new_v4(); - let event = sqlx::query_as!( - Event, - r#"INSERT INTO events ( - id, title, description, start_time, end_time, location, location_url, - category, is_featured, recurring_type, image, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW() - ) RETURNING *"#, - event_id, - pending.title, - sanitized_description, - pending.start_time, - pending.end_time, - pending.location, - pending.location_url, - pending.category, - pending.is_featured.unwrap_or(false), - normalized_recurring_type, - pending.image - ) - .fetch_one(pool) - .await - .map_err(|e| { - tracing::error!("Failed to approve pending event: {}", e); - match e { - sqlx::Error::Database(db_err) if db_err.constraint().is_some() => { - crate::error::ApiError::duplicate_entry("Event", &pending.title) - } - _ => crate::error::ApiError::DatabaseError(e) - } - })?; - - // Remove from pending - sqlx::query!( - "DELETE FROM pending_events WHERE id = $1", - id - ) - .execute(pool) - .await - .map_err(|e| { - tracing::error!("Failed to delete pending event {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - })?; - - Ok(event) - } - - /// Business logic for rejecting pending events - pub async fn reject_pending_event(pool: &PgPool, id: &Uuid, reason: Option) -> Result<()> { - // TODO: Store rejection reason for audit trail - let _ = reason; // Suppress unused warning for now - - let result = sqlx::query!( - "DELETE FROM pending_events WHERE id = $1", - id - ) - .execute(pool) - .await - .map_err(|e| { - tracing::error!("Failed to reject pending event {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - })?; - - if result.rows_affected() == 0 { - return Err(crate::error::ApiError::event_not_found(id)); - } - - Ok(()) - } - - /// Business logic for updating events - pub async fn update_event(pool: &PgPool, id: &Uuid, request: UpdateEventRequest) -> Result { - let sanitized_description = crate::utils::sanitize::strip_html_tags(&request.description); - let normalized_recurring_type = request.recurring_type.as_ref() - .map(|rt| crate::utils::validation::normalize_recurring_type(rt)); - - let event = sqlx::query_as!( - Event, - r#"UPDATE events SET - title = $2, description = $3, start_time = $4, end_time = $5, - location = $6, location_url = $7, category = $8, is_featured = $9, - recurring_type = $10, image = $11, updated_at = NOW() - WHERE id = $1 - RETURNING *"#, - id, - request.title, - sanitized_description, - request.start_time, - request.end_time, - request.location, - request.location_url, - request.category, - request.is_featured.unwrap_or(false), - normalized_recurring_type, - request.image - ) - .fetch_optional(pool) - .await - .map_err(|e| { - tracing::error!("Failed to update event {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - })? - .ok_or_else(|| crate::error::ApiError::NotFound("Event not found".to_string()))?; - - Ok(event) - } - - /// Business logic for deleting events - pub async fn delete_event(pool: &PgPool, id: &Uuid) -> Result<()> { - let result = sqlx::query!( - "DELETE FROM events WHERE id = $1", - id - ) - .execute(pool) - .await - .map_err(|e| { - tracing::error!("Failed to delete event {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - })?; - - if result.rows_affected() == 0 { - return Err(crate::error::ApiError::event_not_found(id)); - } - - Ok(()) - } - - /// Business logic for deleting pending events - pub async fn delete_pending_event(pool: &PgPool, id: &Uuid) -> Result<()> { - let result = sqlx::query!( - "DELETE FROM pending_events WHERE id = $1", - id - ) - .execute(pool) - .await - .map_err(|e| { - tracing::error!("Failed to delete pending event {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - })?; - - if result.rows_affected() == 0 { - return Err(crate::error::ApiError::event_not_found(id)); - } - - Ok(()) - } - - /// Get pending event by ID - pub async fn get_pending_by_id(pool: &PgPool, id: &Uuid) -> Result> { - sqlx::query_as!( - PendingEvent, - "SELECT * FROM pending_events WHERE id = $1", - id - ) - .fetch_optional(pool) - .await - .map_err(|e| { - tracing::error!("Failed to get pending event by id {}: {}", id, e); - crate::error::ApiError::DatabaseError(e) - }) - } - - /// List pending events with V2 timezone conversion - pub async fn list_pending_v2(pool: &PgPool, page: i32, per_page: i32, timezone: &str, url_builder: &UrlBuilder) -> Result> { - let offset = (page - 1) * per_page; - let events = sqlx::query_as!( - PendingEvent, - "SELECT * FROM pending_events ORDER BY submitted_at DESC LIMIT $1 OFFSET $2", - per_page as i64, - offset as i64 - ) - .fetch_all(pool) - .await - .map_err(|e| { - tracing::error!("Failed to list pending events: {}", e); - crate::error::ApiError::DatabaseError(e) - })?; - - let mut events_v2 = Vec::new(); - for event in events { - let event_v2 = crate::utils::converters::convert_pending_event_to_v2(event, timezone, url_builder)?; - events_v2.push(event_v2); - } - Ok(events_v2) - } -} \ No newline at end of file diff --git a/src/services/events_v1.rs b/src/services/events_v1.rs new file mode 100644 index 0000000..e4b21c9 --- /dev/null +++ b/src/services/events_v1.rs @@ -0,0 +1,83 @@ +use sqlx::PgPool; +use uuid::Uuid; +use crate::{ + models::{Event, UpdateEventRequest}, + error::Result, + utils::{ + urls::UrlBuilder, + converters::{convert_events_to_v1, convert_event_to_v1}, + }, + sql::events, +}; + +/// V1 Events API business logic service +/// Handles V1-specific timezone conversion and response formatting +pub struct EventsV1Service; + +impl EventsV1Service { + /// Get upcoming events with V1 timezone conversion + pub async fn get_upcoming(pool: &PgPool, _limit: i64, url_builder: &UrlBuilder) -> Result> { + let events = events::get_upcoming_events(pool, 50).await?; + convert_events_to_v1(events, url_builder) + } + + /// Get featured events with V1 timezone conversion + pub async fn get_featured(pool: &PgPool, _limit: i64, url_builder: &UrlBuilder) -> Result> { + let events = events::get_featured_events(pool, 10).await?; + convert_events_to_v1(events, url_builder) + } + + /// Get all events with V1 timezone conversion and pagination + pub async fn list_all(pool: &PgPool, url_builder: &UrlBuilder) -> Result> { + let events = events::list_all_events(pool).await?; + convert_events_to_v1(events, url_builder) + } + + /// Get single event by ID with V1 timezone conversion + pub async fn get_by_id(pool: &PgPool, id: &Uuid, url_builder: &UrlBuilder) -> Result> { + let event = events::get_event_by_id(pool, id).await?; + + if let Some(event) = event { + let converted = convert_event_to_v1(event, url_builder)?; + Ok(Some(converted)) + } else { + Ok(None) + } + } + + /// Update event with V1 business logic + pub async fn update(pool: &PgPool, id: &Uuid, request: UpdateEventRequest) -> Result { + let sanitized_description = crate::utils::sanitize::strip_html_tags(&request.description); + let normalized_recurring_type = request.recurring_type.as_ref() + .map(|rt| crate::utils::validation::normalize_recurring_type(rt)); + + let event = events::update_event_by_id( + pool, + id, + &request.title, + &sanitized_description, + request.start_time, + request.end_time, + &request.location, + request.location_url.as_deref(), + &request.category, + request.is_featured.unwrap_or(false), + normalized_recurring_type.as_deref(), + request.image.as_deref() + ).await? + .ok_or_else(|| crate::error::ApiError::NotFound("Event not found".to_string()))?; + + Ok(event) + } + + /// Delete event with V1 business logic + pub async fn delete(pool: &PgPool, id: &Uuid) -> Result<()> { + let rows_affected = events::delete_event_by_id(pool, id).await?; + + if rows_affected == 0 { + return Err(crate::error::ApiError::event_not_found(id)); + } + + Ok(()) + } +} \ No newline at end of file diff --git a/src/services/events_v2.rs b/src/services/events_v2.rs new file mode 100644 index 0000000..e9dbbf9 --- /dev/null +++ b/src/services/events_v2.rs @@ -0,0 +1,47 @@ +use sqlx::PgPool; +use uuid::Uuid; +use crate::{ + models::EventV2, + error::Result, + utils::{ + urls::UrlBuilder, + converters::{convert_events_to_v2, convert_event_to_v2}, + }, + sql::events, +}; + +/// V2 Events API business logic service +/// Handles V2-specific timezone conversion and response formatting +pub struct EventsV2Service; + +impl EventsV2Service { + /// Get upcoming events with V2 timezone handling + pub async fn get_upcoming(pool: &PgPool, _limit: i64, timezone: &str, url_builder: &UrlBuilder) -> Result> { + let events = events::get_upcoming_events(pool, 50).await?; + convert_events_to_v2(events, timezone, url_builder) + } + + /// Get featured events with V2 timezone handling + pub async fn get_featured(pool: &PgPool, _limit: i64, timezone: &str, url_builder: &UrlBuilder) -> Result> { + let events = events::get_featured_events(pool, 10).await?; + convert_events_to_v2(events, timezone, url_builder) + } + + /// Get all events with V2 timezone handling and pagination + pub async fn list_all(pool: &PgPool, timezone: &str, url_builder: &UrlBuilder) -> Result> { + let events = events::list_all_events(pool).await?; + convert_events_to_v2(events, timezone, url_builder) + } + + /// Get single event by ID with V2 timezone handling + pub async fn get_by_id(pool: &PgPool, id: &Uuid, timezone: &str, url_builder: &UrlBuilder) -> Result> { + let event = events::get_event_by_id(pool, id).await?; + + if let Some(event) = event { + let converted = convert_event_to_v2(event, timezone, url_builder)?; + Ok(Some(converted)) + } else { + Ok(None) + } + } +} \ No newline at end of file diff --git a/src/services/hymnal.rs b/src/services/hymnal.rs index 7ff99d1..adc7220 100644 --- a/src/services/hymnal.rs +++ b/src/services/hymnal.rs @@ -6,6 +6,7 @@ use crate::{ ResponsiveReadingQuery, HymnalPaginatedResponse, SearchResult }, utils::pagination::PaginationHelper, + sql::hymnal, }; use sqlx::PgPool; use uuid::Uuid; @@ -23,48 +24,15 @@ impl HymnalService { } // Hymnal operations pub async fn list_hymnals(pool: &PgPool) -> Result> { - let hymnals = sqlx::query_as::<_, Hymnal>( - r#" - SELECT id, name, code, description, year, language, is_active, created_at, updated_at - FROM hymnals - WHERE is_active = true - ORDER BY year DESC, name - "# - ) - .fetch_all(pool) - .await?; - - Ok(hymnals) + hymnal::list_hymnals(pool).await } pub async fn get_hymnal_by_id(pool: &PgPool, hymnal_id: Uuid) -> Result> { - let hymnal = sqlx::query_as::<_, Hymnal>( - r#" - SELECT id, name, code, description, year, language, is_active, created_at, updated_at - FROM hymnals - WHERE id = $1 AND is_active = true - "# - ) - .bind(hymnal_id) - .fetch_optional(pool) - .await?; - - Ok(hymnal) + hymnal::get_hymnal_by_id(pool, &hymnal_id).await } pub async fn get_hymnal_by_code(pool: &PgPool, code: &str) -> Result> { - let hymnal = sqlx::query_as::<_, Hymnal>( - r#" - SELECT id, name, code, description, year, language, is_active, created_at, updated_at - FROM hymnals - WHERE code = $1 AND is_active = true - "# - ) - .bind(code) - .fetch_optional(pool) - .await?; - - Ok(hymnal) + hymnal::get_hymnal_by_code(pool, code).await } // Hymn operations @@ -74,56 +42,12 @@ impl HymnalService { pagination: PaginationHelper, ) -> Result> { let hymns = if let Some(hymnal_id) = hymnal_id { - let total_count = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true AND h.hymnal_id = $1" - ) - .bind(hymnal_id) - .fetch_one(pool) - .await?; - - let hymns = sqlx::query_as::<_, HymnWithHymnal>( - r#" - SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, - hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, - h.created_at, h.updated_at - FROM hymns h - JOIN hymnals hy ON h.hymnal_id = hy.id - WHERE hy.is_active = true AND h.hymnal_id = $1 - ORDER BY h.number - LIMIT $2 OFFSET $3 - "# - ) - .bind(hymnal_id) - .bind(pagination.per_page as i64) - .bind(pagination.offset) - .fetch_all(pool) - .await?; - + let total_count = hymnal::count_hymns_in_hymnal(pool, &hymnal_id).await?; + let hymns = hymnal::list_hymns_paginated(pool, &hymnal_id, pagination.per_page as i64, pagination.offset).await?; pagination.create_hymnal_response(hymns, total_count) } else { - let total_count = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true" - ) - .fetch_one(pool) - .await?; - - let hymns = sqlx::query_as::<_, HymnWithHymnal>( - r#" - SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, - hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, - h.created_at, h.updated_at - FROM hymns h - JOIN hymnals hy ON h.hymnal_id = hy.id - WHERE hy.is_active = true - ORDER BY hy.year DESC, h.number - LIMIT $1 OFFSET $2 - "# - ) - .bind(pagination.per_page as i64) - .bind(pagination.offset) - .fetch_all(pool) - .await?; - + let total_count = hymnal::count_all_hymns(pool).await?; + let hymns = hymnal::list_all_hymns_paginated(pool, pagination.per_page as i64, pagination.offset).await?; pagination.create_hymnal_response(hymns, total_count) }; @@ -135,22 +59,9 @@ impl HymnalService { hymnal_code: &str, hymn_number: i32, ) -> Result> { - let hymn = sqlx::query_as::<_, HymnWithHymnal>( - r#" - SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, - hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, - h.created_at, h.updated_at - FROM hymns h - JOIN hymnals hy ON h.hymnal_id = hy.id - WHERE hy.code = $1 AND h.number = $2 AND hy.is_active = true - "# - ) - .bind(hymnal_code) - .bind(hymn_number) - .fetch_optional(pool) - .await?; - - Ok(hymn) + // Use existing sql::hymnal basic search for this simple case + let (results, _) = hymnal::search_hymns_basic(pool, "", Some(hymnal_code), Some(hymn_number), 1, 0).await?; + Ok(results.into_iter().next()) } pub async fn search_hymns( @@ -165,30 +76,8 @@ impl HymnalService { }, // For hymnal listing (no text search), return hymns with default score but in proper order (None, Some(hymnal_code), None, None) => { - let total_count = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true AND hy.code = $1" - ) - .bind(hymnal_code) - .fetch_one(pool) - .await?; - - let hymns = sqlx::query_as::<_, HymnWithHymnal>( - r#" - SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, - hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, - h.created_at, h.updated_at - FROM hymns h - JOIN hymnals hy ON h.hymnal_id = hy.id - WHERE hy.is_active = true AND hy.code = $1 - ORDER BY h.number ASC - LIMIT $2 OFFSET $3 - "# - ) - .bind(hymnal_code) - .bind(pagination.per_page as i64) - .bind(pagination.offset) - .fetch_all(pool) - .await?; + let total_count = hymnal::count_hymns_by_code(pool, hymnal_code).await?; + let hymns = hymnal::list_hymns_by_code_paginated(pool, hymnal_code, pagination.per_page as i64, pagination.offset).await?; // Convert to SearchResult but with predictable ordering and neutral scores let search_results: Vec = hymns.into_iter().map(|hymn| { diff --git a/src/services/mod.rs b/src/services/mod.rs index 94c3f4b..af8d4ec 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,4 +1,6 @@ -pub mod events; +pub mod events_v1; +pub mod events_v2; +pub mod pending_events; pub mod bulletins; pub mod auth; pub mod bible_verses; @@ -13,7 +15,9 @@ pub mod hymnal; pub mod hymnal_search; pub mod members; -pub use events::EventService; +pub use events_v1::EventsV1Service; +pub use events_v2::EventsV2Service; +pub use pending_events::PendingEventsService; pub use bulletins::BulletinService; pub use auth::AuthService; pub use bible_verses::BibleVerseService; diff --git a/src/services/pending_events.rs b/src/services/pending_events.rs new file mode 100644 index 0000000..489a727 --- /dev/null +++ b/src/services/pending_events.rs @@ -0,0 +1,95 @@ +use sqlx::PgPool; +use uuid::Uuid; +use crate::{ + models::{PendingEvent, PendingEventV2, SubmitEventRequest, Event}, + error::Result, + utils::{ + urls::UrlBuilder, + converters::{convert_pending_event_to_v1, convert_pending_events_to_v1, convert_pending_event_to_v2}, + }, + sql::events, +}; + +/// Pending Events business logic service +/// Handles submission, approval, and rejection of pending events +pub struct PendingEventsService; + +impl PendingEventsService { + /// Submit event for approval (public function) + pub async fn submit_for_approval(pool: &PgPool, request: SubmitEventRequest, url_builder: &UrlBuilder) -> Result { + let sanitized_description = crate::utils::sanitize::strip_html_tags(&request.description); + let pending_event = events::create_pending_event(pool, &request, &sanitized_description).await?; + convert_pending_event_to_v1(pending_event, url_builder) + } + + /// Get pending events list (admin function) - V1 format + pub async fn list_v1(pool: &PgPool, page: i32, per_page: i32, url_builder: &UrlBuilder) -> Result> { + let events = events::list_pending_events_paginated(pool, page, per_page).await?; + convert_pending_events_to_v1(events, url_builder) + } + + /// Get pending events list (admin function) - V2 format + pub async fn list_v2(pool: &PgPool, page: i32, per_page: i32, timezone: &str, url_builder: &UrlBuilder) -> Result> { + let events = events::list_pending_events_paginated(pool, page, per_page).await?; + let mut events_v2 = Vec::new(); + for event in events { + let event_v2 = convert_pending_event_to_v2(event, timezone, url_builder)?; + events_v2.push(event_v2); + } + Ok(events_v2) + } + + /// Count pending events (admin function) + pub async fn count_pending(pool: &PgPool) -> Result { + events::count_pending_events(pool).await + } + + /// Get pending event by ID + pub async fn get_by_id(pool: &PgPool, id: &Uuid) -> Result> { + events::get_pending_event_by_id(pool, id).await + } + + /// Business logic for approving pending events + pub async fn approve(pool: &PgPool, id: &Uuid) -> Result { + // Get the pending event + let pending = events::get_pending_event_by_id(pool, id).await? + .ok_or_else(|| crate::error::ApiError::event_not_found(id))?; + + let sanitized_description = crate::utils::sanitize::strip_html_tags(&pending.description); + let normalized_recurring_type = pending.recurring_type.as_ref() + .map(|rt| crate::utils::validation::normalize_recurring_type(rt)); + + // Create approved event + let event = events::create_approved_event(pool, &pending, &sanitized_description, normalized_recurring_type.as_deref()).await?; + + // Remove from pending + events::delete_pending_event_by_id(pool, id).await?; + + Ok(event) + } + + /// Business logic for rejecting pending events + pub async fn reject(pool: &PgPool, id: &Uuid, reason: Option) -> Result<()> { + // TODO: Store rejection reason for audit trail + let _ = reason; // Suppress unused warning for now + + let rows_affected = events::delete_pending_event_by_id(pool, id).await?; + + if rows_affected == 0 { + return Err(crate::error::ApiError::event_not_found(id)); + } + + Ok(()) + } + + /// Delete pending event + pub async fn delete(pool: &PgPool, id: &Uuid) -> Result<()> { + let rows_affected = events::delete_pending_event_by_id(pool, id).await?; + + if rows_affected == 0 { + return Err(crate::error::ApiError::event_not_found(id)); + } + + Ok(()) + } +} \ No newline at end of file diff --git a/src/services/schedule.rs b/src/services/schedule.rs index c4e8d29..d68cf31 100644 --- a/src/services/schedule.rs +++ b/src/services/schedule.rs @@ -1,6 +1,5 @@ use sqlx::PgPool; -use chrono::{NaiveDate, Timelike}; -use uuid::Uuid; +use chrono::NaiveDate; use crate::{ models::{Schedule, ScheduleV2, ScheduleData, ConferenceData, Personnel}, error::{Result, ApiError}, @@ -35,13 +34,7 @@ impl ScheduleService { let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") .map_err(|_| ApiError::BadRequest("Invalid date format. Use YYYY-MM-DD".to_string()))?; - let schedule = sqlx::query_as!( - Schedule, - "SELECT * FROM schedule WHERE date = $1", - date - ) - .fetch_optional(pool) - .await?; + let schedule = schedule::get_schedule_by_date(pool, &date).await?; let personnel = if let Some(s) = schedule { Personnel { @@ -80,30 +73,20 @@ impl ScheduleService { .map_err(|_| ApiError::BadRequest("Invalid date format. Use YYYY-MM-DD".to_string()))?; // Get offering for this date - let offering = sqlx::query!("SELECT offering_type FROM conference_offerings WHERE date = $1", date) - .fetch_optional(pool) - .await?; + let offering = schedule::get_offering_for_date(pool, &date).await?; // Get sunset for this date - let sunset = sqlx::query!("SELECT sunset_time FROM sunset_times WHERE date = $1 AND city = 'Springfield'", date) - .fetch_optional(pool) - .await?; + let sunset = schedule::get_sunset_time(pool, &date, "Springfield").await?; // Get sunset for next week (same date + 7 days) let next_week = date + chrono::Duration::days(7); - let next_week_sunset = sqlx::query!("SELECT sunset_time FROM sunset_times WHERE date = $1 AND city = 'Springfield'", next_week) - .fetch_optional(pool) - .await?; + let next_week_sunset = schedule::get_sunset_time(pool, &next_week, "Springfield").await?; Ok(ConferenceData { date: date_str.to_string(), - offering_focus: offering.map(|o| o.offering_type).unwrap_or("Local Church Budget".to_string()), - sunset_tonight: sunset.map(|s| format!("{}:{:02} pm", - if s.sunset_time.hour() > 12 { s.sunset_time.hour() - 12 } else { s.sunset_time.hour() }, - s.sunset_time.minute())).unwrap_or("8:00 pm".to_string()), - sunset_next_friday: next_week_sunset.map(|s| format!("{}:{:02} pm", - if s.sunset_time.hour() > 12 { s.sunset_time.hour() - 12 } else { s.sunset_time.hour() }, - s.sunset_time.minute())).unwrap_or("8:00 pm".to_string()), + offering_focus: offering.unwrap_or("Local Church Budget".to_string()), + sunset_tonight: sunset.unwrap_or("8:00 pm".to_string()), + sunset_next_friday: next_week_sunset.unwrap_or("8:00 pm".to_string()), }) } @@ -112,68 +95,7 @@ impl ScheduleService { let date = NaiveDate::parse_from_str(&request.date, "%Y-%m-%d") .map_err(|_| ApiError::BadRequest("Invalid date format. Use YYYY-MM-DD".to_string()))?; - let schedule = Schedule { - id: Uuid::new_v4(), - date, - song_leader: request.song_leader, - ss_teacher: request.ss_teacher, - ss_leader: request.ss_leader, - mission_story: request.mission_story, - special_program: request.special_program, - sermon_speaker: request.sermon_speaker, - scripture: request.scripture, - offering: request.offering, - deacons: request.deacons, - special_music: request.special_music, - childrens_story: request.childrens_story, - afternoon_program: request.afternoon_program, - created_at: None, - updated_at: None, - }; - - let result = sqlx::query_as!( - Schedule, - r#" - INSERT INTO schedule ( - id, date, song_leader, ss_teacher, ss_leader, mission_story, - special_program, sermon_speaker, scripture, offering, deacons, - special_music, childrens_story, afternoon_program, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW() - ) - ON CONFLICT (date) DO UPDATE SET - song_leader = EXCLUDED.song_leader, - ss_teacher = EXCLUDED.ss_teacher, - ss_leader = EXCLUDED.ss_leader, - mission_story = EXCLUDED.mission_story, - special_program = EXCLUDED.special_program, - sermon_speaker = EXCLUDED.sermon_speaker, - scripture = EXCLUDED.scripture, - offering = EXCLUDED.offering, - deacons = EXCLUDED.deacons, - special_music = EXCLUDED.special_music, - childrens_story = EXCLUDED.childrens_story, - afternoon_program = EXCLUDED.afternoon_program, - updated_at = NOW() - RETURNING * - "#, - schedule.id, - schedule.date, - schedule.song_leader, - schedule.ss_teacher, - schedule.ss_leader, - schedule.mission_story, - schedule.special_program, - schedule.sermon_speaker, - schedule.scripture, - schedule.offering, - schedule.deacons, - schedule.special_music, - schedule.childrens_story, - schedule.afternoon_program - ) - .fetch_one(pool) - .await?; + let result = schedule::upsert_schedule(pool, &date, &request).await?; Ok(result) } @@ -183,21 +105,14 @@ impl ScheduleService { let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") .map_err(|_| ApiError::BadRequest("Invalid date format. Use YYYY-MM-DD".to_string()))?; - sqlx::query!("DELETE FROM schedule WHERE date = $1", date) - .execute(pool) - .await?; + schedule::delete_schedule_by_date(pool, &date).await?; Ok(()) } /// List all schedules with V1 format pub async fn list_schedules_v1(pool: &PgPool) -> Result> { - let schedules = sqlx::query_as!( - Schedule, - "SELECT * FROM schedule ORDER BY date" - ) - .fetch_all(pool) - .await?; + let schedules = schedule::list_all_schedules(pool).await?; convert_schedules_to_v1(schedules) } @@ -206,13 +121,7 @@ impl ScheduleService { /// Get schedule by date with V2 format (UTC timestamps) pub async fn get_schedule_v2(pool: &PgPool, date: &NaiveDate) -> Result> { - let schedule = sqlx::query_as!( - Schedule, - "SELECT * FROM schedule WHERE date = $1", - date - ) - .fetch_optional(pool) - .await?; + let schedule = schedule::get_schedule_by_date(pool, date).await?; match schedule { Some(s) => { @@ -225,13 +134,7 @@ impl ScheduleService { /// Get conference data for V2 (simplified version) pub async fn get_conference_data_v2(pool: &PgPool, date: &NaiveDate) -> Result { - let schedule = sqlx::query_as!( - Schedule, - "SELECT * FROM schedule WHERE date = $1", - date - ) - .fetch_optional(pool) - .await? + let schedule = schedule::get_schedule_by_date(pool, date).await? .ok_or_else(|| ApiError::NotFound("Schedule not found".to_string()))?; Ok(ConferenceData { diff --git a/src/sql/events.rs b/src/sql/events.rs index 9bd8ba6..477c2c9 100644 --- a/src/sql/events.rs +++ b/src/sql/events.rs @@ -167,4 +167,174 @@ pub async fn count_pending_events(pool: &PgPool) -> Result { })?; Ok(count.count.unwrap_or(0)) +} + +/// List pending events with pagination +pub async fn list_pending_events_paginated(pool: &PgPool, page: i32, per_page: i32) -> Result> { + let offset = (page - 1) * per_page; + sqlx::query_as!( + PendingEvent, + "SELECT * FROM pending_events ORDER BY submitted_at DESC LIMIT $1 OFFSET $2", + per_page as i64, + offset as i64 + ) + .fetch_all(pool) + .await + .map_err(|e| { + tracing::error!("Failed to list pending events: {}", e); + ApiError::DatabaseError(e) + }) +} + +/// Get pending event by ID +pub async fn get_pending_event_by_id(pool: &PgPool, id: &Uuid) -> Result> { + sqlx::query_as!( + PendingEvent, + "SELECT * FROM pending_events WHERE id = $1", + id + ) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to get pending event by id {}: {}", id, e); + ApiError::DatabaseError(e) + }) +} + +/// Create pending event +pub async fn create_pending_event(pool: &PgPool, request: &SubmitEventRequest, sanitized_description: &str) -> Result { + let event_id = uuid::Uuid::new_v4(); + sqlx::query_as!( + PendingEvent, + r#"INSERT INTO pending_events ( + id, title, description, start_time, end_time, location, location_url, + category, is_featured, recurring_type, bulletin_week, submitter_email, + image, thumbnail, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW() + ) RETURNING *"#, + event_id, + request.title, + sanitized_description, + request.start_time, + request.end_time, + request.location, + request.location_url, + request.category, + request.is_featured.unwrap_or(false), + request.recurring_type, + request.bulletin_week, + request.submitter_email, + request.image, + request.thumbnail + ) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to submit pending event: {}", e); + match e { + sqlx::Error::Database(db_err) if db_err.constraint().is_some() => { + ApiError::duplicate_entry("Pending Event", &request.title) + } + _ => ApiError::DatabaseError(e) + } + }) +} + +/// Update event +pub async fn update_event_by_id(pool: &PgPool, id: &Uuid, title: &str, sanitized_description: &str, start_time: DateTime, end_time: DateTime, location: &str, location_url: Option<&str>, category: &str, is_featured: bool, recurring_type: Option<&str>, image: Option<&str>) -> Result> { + sqlx::query_as!( + Event, + r#"UPDATE events SET + title = $2, description = $3, start_time = $4, end_time = $5, + location = $6, location_url = $7, category = $8, is_featured = $9, + recurring_type = $10, image = $11, updated_at = NOW() + WHERE id = $1 + RETURNING *"#, + id, + title, + sanitized_description, + start_time, + end_time, + location, + location_url, + category, + is_featured, + recurring_type, + image + ) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to update event {}: {}", id, e); + ApiError::DatabaseError(e) + }) +} + +/// Delete event by ID +pub async fn delete_event_by_id(pool: &PgPool, id: &Uuid) -> Result { + let result = sqlx::query!( + "DELETE FROM events WHERE id = $1", + id + ) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to delete event {}: {}", id, e); + ApiError::DatabaseError(e) + })?; + + Ok(result.rows_affected()) +} + +/// Delete pending event by ID +pub async fn delete_pending_event_by_id(pool: &PgPool, id: &Uuid) -> Result { + let result = sqlx::query!( + "DELETE FROM pending_events WHERE id = $1", + id + ) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to delete pending event {}: {}", id, e); + ApiError::DatabaseError(e) + })?; + + Ok(result.rows_affected()) +} + +/// Create approved event from pending event data +pub async fn create_approved_event(pool: &PgPool, pending: &PendingEvent, sanitized_description: &str, normalized_recurring_type: Option<&str>) -> Result { + let event_id = Uuid::new_v4(); + sqlx::query_as!( + Event, + r#"INSERT INTO events ( + id, title, description, start_time, end_time, location, location_url, + category, is_featured, recurring_type, image, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW() + ) RETURNING *"#, + event_id, + pending.title, + sanitized_description, + pending.start_time, + pending.end_time, + pending.location, + pending.location_url, + pending.category, + pending.is_featured.unwrap_or(false), + normalized_recurring_type, + pending.image + ) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to approve pending event: {}", e); + match e { + sqlx::Error::Database(db_err) if db_err.constraint().is_some() => { + ApiError::duplicate_entry("Event", &pending.title) + } + _ => ApiError::DatabaseError(e) + } + }) } \ No newline at end of file diff --git a/src/sql/hymnal.rs b/src/sql/hymnal.rs index 62d2d48..23e86a4 100644 --- a/src/sql/hymnal.rs +++ b/src/sql/hymnal.rs @@ -1,6 +1,6 @@ use sqlx::PgPool; use uuid::Uuid; -use crate::{error::Result, models::HymnWithHymnal}; +use crate::{error::Result, models::{HymnWithHymnal, Hymnal}}; /// Basic search query with simplified scoring (raw SQL, no conversion) pub async fn search_hymns_basic( @@ -142,4 +142,152 @@ pub async fn get_hymn_by_id(pool: &PgPool, id: &Uuid) -> Result Result> { + sqlx::query_as::<_, Hymnal>( + r#" + SELECT id, name, code, description, year, language, is_active, created_at, updated_at + FROM hymnals + WHERE is_active = true + ORDER BY year DESC, name + "# + ) + .fetch_all(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e)) +} + +/// Get hymnal by ID +pub async fn get_hymnal_by_id(pool: &PgPool, hymnal_id: &Uuid) -> Result> { + sqlx::query_as::<_, Hymnal>( + r#" + SELECT id, name, code, description, year, language, is_active, created_at, updated_at + FROM hymnals + WHERE id = $1 AND is_active = true + "# + ) + .bind(hymnal_id) + .fetch_optional(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e)) +} + +/// Get hymnal by code +pub async fn get_hymnal_by_code(pool: &PgPool, code: &str) -> Result> { + sqlx::query_as::<_, Hymnal>( + r#" + SELECT id, name, code, description, year, language, is_active, created_at, updated_at + FROM hymnals + WHERE code = $1 AND is_active = true + "# + ) + .bind(code) + .fetch_optional(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e)) +} + +/// Count hymns in specific hymnal +pub async fn count_hymns_in_hymnal(pool: &PgPool, hymnal_id: &Uuid) -> Result { + let count = sqlx::query!( + "SELECT COUNT(*) as count FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE h.hymnal_id = $1 AND hy.is_active = true", + hymnal_id + ) + .fetch_one(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e))?; + + Ok(count.count.unwrap_or(0)) +} + +/// List hymns in specific hymnal with pagination +pub async fn list_hymns_paginated(pool: &PgPool, hymnal_id: &Uuid, limit: i64, offset: i64) -> Result> { + sqlx::query_as!( + HymnWithHymnal, + r#"SELECT + h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE h.hymnal_id = $1 AND hy.is_active = true + ORDER BY h.number + LIMIT $2 OFFSET $3"#, + hymnal_id, + limit, + offset + ) + .fetch_all(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e)) +} + +/// Count all hymns across all hymnals +pub async fn count_all_hymns(pool: &PgPool) -> Result { + let count = sqlx::query!( + "SELECT COUNT(*) as count FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true" + ) + .fetch_one(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e))?; + + Ok(count.count.unwrap_or(0)) +} + +/// List all hymns across all hymnals with pagination +pub async fn list_all_hymns_paginated(pool: &PgPool, limit: i64, offset: i64) -> Result> { + sqlx::query_as!( + HymnWithHymnal, + r#"SELECT + h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true + ORDER BY hy.year DESC, h.number + LIMIT $1 OFFSET $2"#, + limit, + offset + ) + .fetch_all(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e)) +} + +/// Count hymns by hymnal code +pub async fn count_hymns_by_code(pool: &PgPool, hymnal_code: &str) -> Result { + let count = sqlx::query!( + "SELECT COUNT(*) as count FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true AND hy.code = $1", + hymnal_code + ) + .fetch_one(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e))?; + + Ok(count.count.unwrap_or(0)) +} + +/// List hymns by hymnal code with pagination +pub async fn list_hymns_by_code_paginated(pool: &PgPool, hymnal_code: &str, limit: i64, offset: i64) -> Result> { + sqlx::query_as!( + HymnWithHymnal, + r#"SELECT + h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true AND hy.code = $1 + ORDER BY h.number ASC + LIMIT $2 OFFSET $3"#, + hymnal_code, + limit, + offset + ) + .fetch_all(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e)) } \ No newline at end of file