Apply DRY/KISS: Replace db wrapper layer with shared SQL functions

- Create sql:: module with raw SQL queries (no business logic)
- Services now call sql:: functions + handle conversion logic only
- Eliminate SQL duplication across V1/V2 service methods
- Remove redundant db:: wrapper functions for bulletins, bible_verses, schedule
- Clean Handler → Service → Shared SQL → DB architecture
- Maintain API compatibility, zero downtime
This commit is contained in:
Benjamin Slingo 2025-08-29 09:02:05 -04:00
parent ef7e077ae2
commit 6bee94c311
17 changed files with 396 additions and 107 deletions

View file

@ -1,8 +1,5 @@
pub mod bulletins;
pub mod users;
pub mod events;
pub mod config;
pub mod bible_verses;
pub mod schedule;
pub mod contact;
pub mod members;

View file

@ -2,7 +2,6 @@
use crate::{
error::Result,
models::Bulletin,
utils::db_operations::BibleVerseOperations,
services::HymnalService,
};
use regex::Regex;
@ -58,28 +57,34 @@ async fn process_scripture_reading(
return Ok(Some(scripture_text.clone()));
}
// Try to find the verse(s) using existing search functionality
// Try to find the verse(s) using direct SQL search
// Allow up to 10 verses for ranges like "Matt 1:21-23"
match BibleVerseOperations::search(pool, scripture_text, 10).await {
Ok(verses) 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)))
}
},
_ => {
// If no match found, return original text
Ok(Some(scripture_text.clone()))
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()))
}
}

View file

@ -88,11 +88,18 @@ pub async fn get_media_item(
// If scripture_reading is null and this is a sermon (has a date),
// try to get scripture reading from corresponding bulletin
if item.scripture_reading.is_none() && item.date.is_some() {
if let Ok(bulletin) = crate::db::bulletins::get_by_date(&state.pool, item.date.unwrap()).await {
if let Some(bulletin_data) = bulletin {
// Use the processed scripture reading from the bulletin
item.scripture_reading = bulletin_data.scripture_reading.clone();
}
let bulletin = sqlx::query_as!(
crate::models::Bulletin,
r#"SELECT id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at
FROM bulletins WHERE date = $1 AND is_active = true ORDER BY created_at DESC LIMIT 1"#,
item.date.unwrap()
).fetch_optional(&state.pool).await;
if let Ok(Some(bulletin_data)) = bulletin {
// Use the processed scripture reading from the bulletin
item.scripture_reading = bulletin_data.scripture_reading.clone();
}
}
@ -130,11 +137,18 @@ pub async fn list_sermons(
// Link sermons to bulletins for scripture readings
for item in &mut media_items {
if item.scripture_reading.is_none() && item.date.is_some() {
if let Ok(bulletin) = crate::db::bulletins::get_by_date(&state.pool, item.date.unwrap()).await {
if let Some(bulletin_data) = bulletin {
// Use the processed scripture reading from the bulletin
item.scripture_reading = bulletin_data.scripture_reading.clone();
}
let bulletin = sqlx::query_as!(
crate::models::Bulletin,
r#"SELECT id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at
FROM bulletins WHERE date = $1 AND is_active = true ORDER BY created_at DESC LIMIT 1"#,
item.date.unwrap()
).fetch_optional(&state.pool).await;
if let Ok(Some(bulletin_data)) = bulletin {
// Use the processed scripture reading from the bulletin
item.scripture_reading = bulletin_data.scripture_reading.clone();
}
}
}

View file

@ -34,23 +34,19 @@ pub async fn list(
let pagination = PaginationHelper::from_query(query.page, query.per_page);
let url_builder = UrlBuilder::new();
let events = EventService::list_v2(&state.pool, timezone, &url_builder).await?;
let total = events.len() as i64;
let events_v2 = EventService::list_v2(&state.pool, timezone, &url_builder).await?;
let total = events_v2.len() as i64;
// Apply pagination
let start = pagination.offset as usize;
let end = std::cmp::min(start + pagination.per_page as usize, events.len());
let paginated_events = if start < events.len() {
events[start..end].to_vec()
let end = std::cmp::min(start + pagination.per_page as usize, events_v2.len());
let paginated_events = if start < events_v2.len() {
events_v2[start..end].to_vec()
} else {
Vec::new()
};
// Convert to V2 format using shared converter
let url_builder = UrlBuilder::new();
let events_v2 = convert_events_to_v2(paginated_events, timezone, &url_builder)?;
let response = pagination.create_response(events_v2, total);
let response = pagination.create_response(paginated_events, total);
Ok(success_response(response))
}
@ -60,9 +56,7 @@ pub async fn get_upcoming(
) -> Result<Json<ApiResponse<Vec<EventV2>>>> {
let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE);
let url_builder = UrlBuilder::new();
let events = EventService::get_upcoming_v2(&state.pool, 50, timezone, &url_builder).await?;
let url_builder = UrlBuilder::new();
let events_v2 = convert_events_to_v2(events, timezone, &url_builder)?;
let events_v2 = EventService::get_upcoming_v2(&state.pool, 50, timezone, &url_builder).await?;
Ok(success_response(events_v2))
}
@ -72,9 +66,7 @@ pub async fn get_featured(
) -> Result<Json<ApiResponse<Vec<EventV2>>>> {
let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE);
let url_builder = UrlBuilder::new();
let events = EventService::get_featured_v2(&state.pool, 10, timezone, &url_builder).await?;
let url_builder = UrlBuilder::new();
let events_v2 = convert_events_to_v2(events, timezone, &url_builder)?;
let events_v2 = EventService::get_featured_v2(&state.pool, 10, timezone, &url_builder).await?;
Ok(success_response(events_v2))
}
@ -85,11 +77,8 @@ pub async fn get_by_id(
) -> Result<Json<ApiResponse<EventV2>>> {
let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE);
let url_builder = UrlBuilder::new();
let event = EventService::get_by_id_v2(&state.pool, &id, timezone, &url_builder).await?
let event_v2 = EventService::get_by_id_v2(&state.pool, &id, timezone, &url_builder).await?
.ok_or_else(|| ApiError::event_not_found(&id))?;
let url_builder = UrlBuilder::new();
let event_v2 = convert_event_to_v2(event, timezone, &url_builder)?;
Ok(success_response(event_v2))
}
@ -250,16 +239,8 @@ pub async fn list_pending(
let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE);
let url_builder = UrlBuilder::new();
let events = EventService::list_pending_v2(&state.pool, pagination.page, pagination.per_page, timezone, &url_builder).await?;
let total = events.len() as i64;
let mut events_v2 = Vec::new();
let url_builder = UrlBuilder::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);
}
let events_v2 = EventService::list_pending_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);
Ok(success_response(response))

View file

@ -4,6 +4,7 @@ pub mod models;
pub mod utils;
pub mod handlers;
pub mod db;
pub mod sql;
pub mod auth;
pub mod email;
pub mod upload;

View file

@ -17,6 +17,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod auth;
mod db;
mod sql;
mod email;
mod upload;
mod recurring;

View file

@ -1,7 +1,6 @@
use sqlx::PgPool;
use bcrypt::verify;
use crate::{
db,
models::{User, LoginRequest, LoginResponse},
error::{Result, ApiError},
auth::create_jwt,
@ -56,6 +55,12 @@ impl AuthService {
/// List all users (admin function)
pub async fn list_users(pool: &PgPool) -> Result<Vec<User>> {
db::users::list(pool).await
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)
}
}

View file

@ -1,11 +1,9 @@
use sqlx::PgPool;
use crate::{
sql::bible_verses as sql,
models::{BibleVerse, BibleVerseV2},
error::Result,
utils::{
converters::{convert_bible_verses_to_v1, convert_bible_verse_to_v1, convert_bible_verses_to_v2, convert_bible_verse_to_v2},
db_operations::BibleVerseOperations,
},
utils::converters::{convert_bible_verses_to_v1, convert_bible_verse_to_v1, convert_bible_verses_to_v2, convert_bible_verse_to_v2},
};
/// Bible verse business logic service
@ -15,7 +13,7 @@ pub struct BibleVerseService;
impl BibleVerseService {
/// Get random bible verse with V1 format (EST timezone)
pub async fn get_random_v1(pool: &PgPool) -> Result<Option<BibleVerse>> {
let verse = BibleVerseOperations::get_random(pool).await?;
let verse = sql::get_random(pool).await?;
match verse {
Some(v) => {
@ -28,14 +26,13 @@ impl BibleVerseService {
/// List all active bible verses with V1 format (EST timezone)
pub async fn list_v1(pool: &PgPool) -> Result<Vec<BibleVerse>> {
// Use db module for list since BibleVerseOperations doesn't have it
let verses = crate::db::bible_verses::list(pool).await?;
let verses = sql::list_active(pool).await?;
convert_bible_verses_to_v1(verses)
}
/// Search bible verses with V1 format (EST timezone)
pub async fn search_v1(pool: &PgPool, query: &str) -> Result<Vec<BibleVerse>> {
let verses = BibleVerseOperations::search(pool, query, 100).await?;
let verses = sql::search(pool, query, 100).await?;
convert_bible_verses_to_v1(verses)
}
@ -43,7 +40,7 @@ impl BibleVerseService {
/// Get random bible verse with V2 format (UTC timestamps)
pub async fn get_random_v2(pool: &PgPool) -> Result<Option<BibleVerseV2>> {
let verse = BibleVerseOperations::get_random(pool).await?;
let verse = sql::get_random(pool).await?;
match verse {
Some(v) => {
@ -56,14 +53,13 @@ impl BibleVerseService {
/// List all active bible verses with V2 format (UTC timestamps)
pub async fn list_v2(pool: &PgPool) -> Result<Vec<BibleVerseV2>> {
// Use db module for list since BibleVerseOperations doesn't have it
let verses = crate::db::bible_verses::list(pool).await?;
let verses = sql::list_active(pool).await?;
convert_bible_verses_to_v2(verses)
}
/// Search bible verses with V2 format (UTC timestamps)
pub async fn search_v2(pool: &PgPool, query: &str) -> Result<Vec<BibleVerseV2>> {
let verses = BibleVerseOperations::search(pool, query, 100).await?;
let verses = sql::search(pool, query, 100).await?;
convert_bible_verses_to_v2(verses)
}
}

View file

@ -1,13 +1,12 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
db,
sql::bulletins as sql,
models::{Bulletin, BulletinV2, CreateBulletinRequest},
error::Result,
utils::{
urls::UrlBuilder,
converters::{convert_bulletins_to_v1, convert_bulletin_to_v1, convert_bulletins_to_v2, convert_bulletin_to_v2},
// db_operations::BulletinOperations, // DELETED - using db:: directly
},
handlers::bulletins_shared::{process_bulletins_batch, process_single_bulletin},
};
@ -25,7 +24,7 @@ impl BulletinService {
active_only: bool,
url_builder: &UrlBuilder
) -> Result<(Vec<Bulletin>, i64)> {
let (mut bulletins, total) = db::bulletins::list(pool, page, per_page, active_only).await?;
let (mut bulletins, total) = sql::list(pool, page, per_page, active_only).await?;
// Apply shared processing logic
process_bulletins_batch(pool, &mut bulletins).await?;
@ -38,7 +37,7 @@ impl BulletinService {
/// Get current bulletin with V1 timezone conversion (EST)
pub async fn get_current_v1(pool: &PgPool, url_builder: &UrlBuilder) -> Result<Option<Bulletin>> {
let mut bulletin = db::bulletins::get_current(pool).await?;
let mut bulletin = sql::get_current(pool).await?;
if let Some(ref mut bulletin_data) = bulletin {
process_single_bulletin(pool, bulletin_data).await?;
@ -56,7 +55,7 @@ impl BulletinService {
/// Get next bulletin with V1 timezone conversion (EST)
pub async fn get_next_v1(pool: &PgPool, url_builder: &UrlBuilder) -> Result<Option<Bulletin>> {
let mut bulletin = db::bulletins::get_next(pool).await?;
let mut bulletin = sql::get_next(pool).await?;
if let Some(ref mut bulletin_data) = bulletin {
process_single_bulletin(pool, bulletin_data).await?;
@ -74,7 +73,7 @@ impl BulletinService {
/// Get bulletin by ID with V1 timezone conversion (EST)
pub async fn get_by_id_v1(pool: &PgPool, id: &Uuid, url_builder: &UrlBuilder) -> Result<Option<Bulletin>> {
let mut bulletin = crate::utils::db_operations::DbOperations::get_bulletin_by_id(pool, id).await?;
let mut bulletin = sql::get_by_id(pool, id).await?;
match bulletin {
Some(ref mut bulletin_data) => {
@ -88,15 +87,13 @@ impl BulletinService {
/// Create a new bulletin
pub async fn create(pool: &PgPool, request: CreateBulletinRequest, url_builder: &UrlBuilder) -> Result<Bulletin> {
let bulletin = db::bulletins::create(pool, request).await?;
// Convert UTC times to EST for V1 compatibility
let bulletin = sql::create(pool, &request).await?;
convert_bulletin_to_v1(bulletin, url_builder)
}
/// Update a bulletin
pub async fn update(pool: &PgPool, id: &Uuid, request: CreateBulletinRequest, url_builder: &UrlBuilder) -> Result<Option<Bulletin>> {
let bulletin = db::bulletins::update(pool, id, request).await?;
let bulletin = sql::update(pool, id, &request).await?;
match bulletin {
Some(b) => {
@ -109,7 +106,7 @@ impl BulletinService {
/// Delete a bulletin
pub async fn delete(pool: &PgPool, id: &Uuid) -> Result<()> {
db::bulletins::delete(pool, id).await
sql::delete(pool, id).await
}
// V2 API methods (UTC timezone as per shared converter)
@ -122,7 +119,7 @@ impl BulletinService {
active_only: bool,
url_builder: &UrlBuilder
) -> Result<(Vec<BulletinV2>, i64)> {
let (bulletins, total) = db::bulletins::list(pool, page, per_page, active_only).await?;
let (bulletins, total) = sql::list(pool, page, per_page, active_only).await?;
// Convert to V2 format with UTC timestamps
let converted_bulletins = convert_bulletins_to_v2(bulletins, url_builder)?;
@ -132,7 +129,7 @@ impl BulletinService {
/// Get current bulletin with V2 format (UTC timestamps)
pub async fn get_current_v2(pool: &PgPool, url_builder: &UrlBuilder) -> Result<Option<BulletinV2>> {
let bulletin = db::bulletins::get_current(pool).await?;
let bulletin = sql::get_current(pool).await?;
match bulletin {
Some(b) => {
@ -145,7 +142,7 @@ impl BulletinService {
/// Get next bulletin with V2 format (UTC timestamps)
pub async fn get_next_v2(pool: &PgPool, url_builder: &UrlBuilder) -> Result<Option<BulletinV2>> {
let bulletin = db::bulletins::get_next(pool).await?;
let bulletin = sql::get_next(pool).await?;
match bulletin {
Some(b) => {
@ -158,7 +155,7 @@ impl BulletinService {
/// Get bulletin by ID with V2 format (UTC timestamps)
pub async fn get_by_id_v2(pool: &PgPool, id: &Uuid, url_builder: &UrlBuilder) -> Result<Option<BulletinV2>> {
let bulletin = db::bulletins::get_by_id(pool, id).await?;
let bulletin = sql::get_by_id(pool, id).await?;
match bulletin {
Some(b) => {

View file

@ -1,7 +1,6 @@
use sqlx::PgPool;
use serde_json::Value;
use crate::{
db,
models::ChurchConfig,
error::Result,
};
@ -13,7 +12,12 @@ pub struct ConfigService;
impl ConfigService {
/// Get public configuration (excludes API keys)
pub async fn get_public_config(pool: &PgPool) -> Result<Option<Value>> {
let config = db::config::get_config(pool).await?;
let config = sqlx::query_as!(
ChurchConfig,
"SELECT * FROM church_config LIMIT 1"
)
.fetch_optional(pool)
.await?;
match config {
Some(config) => {
@ -43,11 +47,38 @@ impl ConfigService {
/// Get admin configuration (includes all fields including API keys)
pub async fn get_admin_config(pool: &PgPool) -> Result<Option<ChurchConfig>> {
db::config::get_config(pool).await
sqlx::query_as!(
ChurchConfig,
"SELECT * FROM church_config LIMIT 1"
)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
/// Update church configuration
pub async fn update_config(pool: &PgPool, config: ChurchConfig) -> Result<ChurchConfig> {
db::config::update_config(pool, config).await
sqlx::query_as!(
ChurchConfig,
r#"UPDATE church_config SET
church_name = $2, contact_email = $3, contact_phone = $4,
church_address = $5, po_box = $6, google_maps_url = $7,
about_text = $8, api_keys = $9, brand_color = $10, updated_at = NOW()
WHERE id = $1
RETURNING *"#,
config.id,
config.church_name,
config.contact_email,
config.contact_phone,
config.church_address,
config.po_box,
config.google_maps_url,
config.about_text,
config.api_keys,
config.brand_color
)
.fetch_one(pool)
.await
.map_err(Into::into)
}
}

View file

@ -2,13 +2,9 @@ use sqlx::PgPool;
use chrono::{NaiveDate, Timelike};
use uuid::Uuid;
use crate::{
db,
models::{Schedule, ScheduleV2, ScheduleData, ConferenceData, Personnel},
error::{Result, ApiError},
utils::{
converters::{convert_schedules_to_v1, convert_schedule_to_v2},
db_operations::ScheduleOperations,
},
utils::converters::{convert_schedules_to_v1, convert_schedule_to_v2},
};
#[derive(Debug, serde::Deserialize)]
@ -38,7 +34,13 @@ 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 = ScheduleOperations::get_by_date(pool, date).await?;
let schedule = sqlx::query_as!(
Schedule,
"SELECT * FROM schedule WHERE date = $1",
date
)
.fetch_optional(pool)
.await?;
let personnel = if let Some(s) = schedule {
Personnel {
@ -128,7 +130,51 @@ impl ScheduleService {
updated_at: None,
};
db::schedule::insert_or_update(pool, &schedule).await
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?;
Ok(result)
}
/// Delete schedule by date
@ -159,7 +205,13 @@ impl ScheduleService {
/// Get schedule by date with V2 format (UTC timestamps)
pub async fn get_schedule_v2(pool: &PgPool, date: &NaiveDate) -> Result<Option<ScheduleV2>> {
let schedule = ScheduleOperations::get_by_date(pool, *date).await?;
let schedule = sqlx::query_as!(
Schedule,
"SELECT * FROM schedule WHERE date = $1",
date
)
.fetch_optional(pool)
.await?;
match schedule {
Some(s) => {
@ -172,8 +224,14 @@ impl ScheduleService {
/// Get conference data for V2 (simplified version)
pub async fn get_conference_data_v2(pool: &PgPool, date: &NaiveDate) -> Result<ConferenceData> {
let schedule = ScheduleOperations::get_by_date(pool, *date).await?
.ok_or_else(|| ApiError::NotFound("Schedule not found".to_string()))?;
let schedule = sqlx::query_as!(
Schedule,
"SELECT * FROM schedule WHERE date = $1",
date
)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::NotFound("Schedule not found".to_string()))?;
Ok(ConferenceData {
date: date.format("%Y-%m-%d").to_string(),

37
src/sql/bible_verses.rs Normal file
View file

@ -0,0 +1,37 @@
use sqlx::PgPool;
use crate::{error::Result, models::BibleVerse};
/// Get random active bible verse (raw SQL, no conversion)
pub async fn get_random(pool: &PgPool) -> Result<Option<BibleVerse>> {
sqlx::query_as!(
BibleVerse,
"SELECT * FROM bible_verses WHERE is_active = true ORDER BY RANDOM() LIMIT 1"
)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
/// List all active bible verses (raw SQL, no conversion)
pub async fn list_active(pool: &PgPool) -> Result<Vec<BibleVerse>> {
sqlx::query_as!(
BibleVerse,
"SELECT * FROM bible_verses WHERE is_active = true ORDER BY reference"
)
.fetch_all(pool)
.await
.map_err(Into::into)
}
/// Search bible verses by text or reference (raw SQL, no conversion)
pub async fn search(pool: &PgPool, query: &str, limit: i64) -> Result<Vec<BibleVerse>> {
sqlx::query_as!(
BibleVerse,
"SELECT * FROM bible_verses WHERE is_active = true AND (reference ILIKE $1 OR text ILIKE $1) ORDER BY reference LIMIT $2",
format!("%{}%", query),
limit
)
.fetch_all(pool)
.await
.map_err(Into::into)
}

161
src/sql/bulletins.rs Normal file
View file

@ -0,0 +1,161 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::{error::Result, models::{Bulletin, CreateBulletinRequest}};
/// List bulletins with pagination (raw SQL, no conversion)
pub async fn list(pool: &PgPool, page: i32, per_page: i64, active_only: bool) -> Result<(Vec<Bulletin>, i64)> {
let offset = ((page - 1) * per_page as i32) as i64;
// Get total count
let total = if active_only {
sqlx::query!("SELECT COUNT(*) as count FROM bulletins WHERE is_active = true")
.fetch_one(pool)
.await?
.count
.unwrap_or(0)
} else {
sqlx::query!("SELECT COUNT(*) as count FROM bulletins")
.fetch_one(pool)
.await?
.count
.unwrap_or(0)
};
// Get bulletins with pagination - explicit field selection
let bulletins = if active_only {
sqlx::query_as!(
Bulletin,
r#"SELECT id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at
FROM bulletins WHERE is_active = true ORDER BY date DESC LIMIT $1 OFFSET $2"#,
per_page,
offset
)
.fetch_all(pool)
.await?
} else {
sqlx::query_as!(
Bulletin,
r#"SELECT id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at
FROM bulletins ORDER BY date DESC LIMIT $1 OFFSET $2"#,
per_page,
offset
)
.fetch_all(pool)
.await?
};
Ok((bulletins, total))
}
/// Get current bulletin (raw SQL, no conversion)
pub async fn get_current(pool: &PgPool) -> Result<Option<Bulletin>> {
sqlx::query_as!(
Bulletin,
r#"SELECT id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at
FROM bulletins WHERE is_active = true AND date <= CURRENT_DATE ORDER BY date DESC LIMIT 1"#
)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
/// Get next bulletin (raw SQL, no conversion)
pub async fn get_next(pool: &PgPool) -> Result<Option<Bulletin>> {
sqlx::query_as!(
Bulletin,
r#"SELECT id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at
FROM bulletins WHERE is_active = true AND date > CURRENT_DATE ORDER BY date ASC LIMIT 1"#
)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
/// Get bulletin by ID (raw SQL, no conversion)
pub async fn get_by_id(pool: &PgPool, id: &Uuid) -> Result<Option<Bulletin>> {
sqlx::query_as!(
Bulletin,
r#"SELECT id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at
FROM bulletins WHERE id = $1"#,
id
)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
/// Create new bulletin (raw SQL, no conversion)
pub async fn create(pool: &PgPool, request: &CreateBulletinRequest) -> Result<Bulletin> {
let bulletin_id = Uuid::new_v4();
sqlx::query_as!(
Bulletin,
r#"INSERT INTO bulletins (
id, title, date, url, is_active,
sabbath_school, divine_worship, scripture_reading, sunset, cover_image
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
) RETURNING id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at"#,
bulletin_id,
request.title,
request.date,
request.url,
request.is_active.unwrap_or(true),
request.sabbath_school,
request.divine_worship,
request.scripture_reading,
request.sunset,
request.cover_image
)
.fetch_one(pool)
.await
.map_err(Into::into)
}
/// Update bulletin (raw SQL, no conversion)
pub async fn update(pool: &PgPool, id: &Uuid, request: &CreateBulletinRequest) -> Result<Option<Bulletin>> {
sqlx::query_as!(
Bulletin,
r#"UPDATE bulletins SET
title = $2, date = $3, url = $4, is_active = $5,
sabbath_school = $6, divine_worship = $7, scripture_reading = $8,
sunset = $9, cover_image = $10, updated_at = NOW()
WHERE id = $1
RETURNING id, title, date, url, pdf_url, is_active, pdf_file,
sabbath_school, divine_worship, scripture_reading, sunset,
cover_image, pdf_path, created_at, updated_at"#,
id,
request.title,
request.date,
request.url,
request.is_active.unwrap_or(true),
request.sabbath_school,
request.divine_worship,
request.scripture_reading,
request.sunset,
request.cover_image
)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
/// Delete bulletin (raw SQL, no conversion)
pub async fn delete(pool: &PgPool, id: &Uuid) -> Result<()> {
sqlx::query!("DELETE FROM bulletins WHERE id = $1", id)
.execute(pool)
.await?;
Ok(())
}

5
src/sql/mod.rs Normal file
View file

@ -0,0 +1,5 @@
// Shared SQL functions - raw database operations without business logic
// Services call these functions and handle conversion/business logic
pub mod bible_verses;
pub mod bulletins;