diff --git a/import_quarterlies.py b/import_quarterlies.py new file mode 100644 index 0000000..534c57e --- /dev/null +++ b/import_quarterlies.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import json +import requests +import sys + +def import_quarterlies(): + # Read the quarterly data + with open('/home/rockvilleav/Nextcloud/quarterlies_orders_autosave.json', 'r') as f: + data = json.load(f) + + # Transform the data to match our API format + import_data = { + "orders": [] + } + + for order in data["orders"]: + import_data["orders"].append({ + "name": order["name"], + "type": order["type"], + "amount": order["amount"] + }) + + # Print the data that will be imported + print(f"Importing {len(import_data['orders'])} quarterly orders...") + print("\nFirst few orders:") + for i, order in enumerate(import_data["orders"][:5]): + print(f" {i+1}. {order['name']} - {order['type']} (x{order['amount']})") + + if len(import_data["orders"]) > 5: + print(f" ... and {len(import_data['orders']) - 5} more") + + # Save the transformed data for manual import via API + with open('/opt/rtsda/church-api/quarterly_import_data.json', 'w') as f: + json.dump(import_data, f, indent=2) + + print(f"\nTransformed data saved to: /opt/rtsda/church-api/quarterly_import_data.json") + print("You can now use this file to import via the API endpoint:") + print("curl -X POST http://localhost:3002/api/admin/quarterlies/import \\") + print(" -H 'Content-Type: application/json' \\") + print(" -H 'Authorization: Bearer YOUR_JWT_TOKEN' \\") + print(" -d @quarterly_import_data.json") + +if __name__ == "__main__": + import_quarterlies() \ No newline at end of file diff --git a/import_to_db.py b/import_to_db.py new file mode 100644 index 0000000..2397256 --- /dev/null +++ b/import_to_db.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +import json +import psycopg2 +import uuid +from datetime import datetime + +def import_to_database(): + # Read the transformed JSON data + with open('/opt/rtsda/church-api/quarterly_import_data.json', 'r') as f: + data = json.load(f) + + # Connect to the database + conn = psycopg2.connect("postgresql://rtsda_user:SaviourofMyLife!!@localhost:5432/church_db") + cursor = conn.cursor() + + print(f"Importing {len(data['orders'])} quarterly orders to database...") + + imported = 0 + errors = 0 + + for order in data["orders"]: + try: + # Generate a new UUID for each order + order_id = str(uuid.uuid4()) + + # Insert the order + cursor.execute(""" + INSERT INTO quarterly_orders (id, name, quarterly_type, amount) + VALUES (%s, %s, %s, %s) + """, (order_id, order["name"], order["type"], order["amount"])) + + imported += 1 + if imported % 10 == 0: + print(f" Imported {imported} orders...") + + except Exception as e: + print(f" Error importing '{order['name']}': {e}") + errors += 1 + + # Commit all changes + conn.commit() + cursor.close() + conn.close() + + print(f"\nImport completed!") + print(f" Successfully imported: {imported} orders") + print(f" Errors: {errors}") + +if __name__ == "__main__": + import_to_database() \ No newline at end of file diff --git a/migrations/20250906000001_create_quarterly_orders_table.sql b/migrations/20250906000001_create_quarterly_orders_table.sql new file mode 100644 index 0000000..0849869 --- /dev/null +++ b/migrations/20250906000001_create_quarterly_orders_table.sql @@ -0,0 +1,29 @@ +-- Create quarterly_orders table for managing Sabbath School quarterly orders +CREATE TABLE quarterly_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + quarterly_type VARCHAR(50) NOT NULL CHECK (quarterly_type IN ('Regular', 'Teachers', 'Large Print', 'EG White Notes')), + amount INTEGER NOT NULL CHECK (amount > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create index on name for faster searching +CREATE INDEX idx_quarterly_orders_name ON quarterly_orders(name); + +-- Create index on quarterly_type for filtering +CREATE INDEX idx_quarterly_orders_type ON quarterly_orders(quarterly_type); + +-- Create trigger to automatically update updated_at column +CREATE OR REPLACE FUNCTION update_quarterly_orders_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_quarterly_orders_updated_at + BEFORE UPDATE ON quarterly_orders + FOR EACH ROW + EXECUTE FUNCTION update_quarterly_orders_updated_at(); \ No newline at end of file diff --git a/quarterly_import_data.json b/quarterly_import_data.json new file mode 100644 index 0000000..cc47be6 --- /dev/null +++ b/quarterly_import_data.json @@ -0,0 +1,264 @@ +{ + "orders": [ + { + "name": "Clyvia Blackmore", + "type": "Regular", + "amount": 1 + }, + { + "name": "Tara Bradford", + "type": "Regular", + "amount": 1 + }, + { + "name": "Brown Family", + "type": "Teachers", + "amount": 1 + }, + { + "name": "Carleen Brown", + "type": "Regular", + "amount": 1 + }, + { + "name": "Brown Family", + "type": "Regular", + "amount": 1 + }, + { + "name": "Fostena Byron", + "type": "Regular", + "amount": 1 + }, + { + "name": "Louise Candee", + "type": "Regular", + "amount": 1 + }, + { + "name": "Louise Candee", + "type": "Teachers", + "amount": 1 + }, + { + "name": "Louise Candee", + "type": "EG White Notes", + "amount": 1 + }, + { + "name": "Edwardo Carcache", + "type": "Regular", + "amount": 1 + }, + { + "name": "Orville Castillo", + "type": "Teachers", + "amount": 1 + }, + { + "name": "Karen Castillo", + "type": "Regular", + "amount": 1 + }, + { + "name": "Karen Castillo", + "type": "EG White Notes", + "amount": 1 + }, + { + "name": "Nadine Cross", + "type": "Regular", + "amount": 3 + }, + { + "name": "Carlos Domenech", + "type": "Regular", + "amount": 1 + }, + { + "name": "Flick Family", + "type": "Regular", + "amount": 2 + }, + { + "name": "Claire Garrett", + "type": "Regular", + "amount": 1 + }, + { + "name": "Samantha Gayle", + "type": "Regular", + "amount": 1 + }, + { + "name": "Maria Gonzales", + "type": "Large Print", + "amount": 1 + }, + { + "name": "Greeter Station", + "type": "Large Print", + "amount": 1 + }, + { + "name": "Hamilton Family", + "type": "Large Print", + "amount": 2 + }, + { + "name": "Chris Isaacs", + "type": "Regular", + "amount": 1 + }, + { + "name": "Jaffat Family", + "type": "Regular", + "amount": 2 + }, + { + "name": "Angie Johnson", + "type": "Regular", + "amount": 1 + }, + { + "name": "Denova Jones", + "type": "Regular", + "amount": 1 + }, + { + "name": "Linda Lahart", + "type": "Large Print", + "amount": 1 + }, + { + "name": "James Lee", + "type": "Large Print", + "amount": 1 + }, + { + "name": "Patricia Lemone", + "type": "Regular", + "amount": 1 + }, + { + "name": "Lera Little", + "type": "Large Print", + "amount": 1 + }, + { + "name": "Michelle Maitland", + "type": "Regular", + "amount": 1 + }, + { + "name": "Malcolm Matthews", + "type": "Large Print", + "amount": 1 + }, + { + "name": "Kalee Mead", + "type": "Regular", + "amount": 2 + }, + { + "name": "Denise Millany", + "type": "Regular", + "amount": 1 + }, + { + "name": "Sarah Olchanowski", + "type": "Regular", + "amount": 1 + }, + { + "name": "John Oliver", + "type": "Regular", + "amount": 1 + }, + { + "name": "Pinnock Family", + "type": "Regular", + "amount": 1 + }, + { + "name": "Deborah Queen", + "type": "Large Print", + "amount": 1 + }, + { + "name": "Rich Raider", + "type": "Regular", + "amount": 1 + }, + { + "name": "Rawlins Family", + "type": "Teachers", + "amount": 1 + }, + { + "name": "Marsha Reid", + "type": "Regular", + "amount": 1 + }, + { + "name": "Joe Roberts", + "type": "Regular", + "amount": 1 + }, + { + "name": "Rosa Family", + "type": "Regular", + "amount": 1 + }, + { + "name": "La Sala Roy", + "type": "Regular", + "amount": 1 + }, + { + "name": "Dan Sedgewick", + "type": "Regular", + "amount": 1 + }, + { + "name": "Dan Sedgewick", + "type": "EG White Notes", + "amount": 1 + }, + { + "name": "Mildred Stephens", + "type": "Regular", + "amount": 1 + }, + { + "name": "Jheanell Thomas", + "type": "Regular", + "amount": 1 + }, + { + "name": "Wayne Tino", + "type": "Teachers", + "amount": 1 + }, + { + "name": "Jerry Travers", + "type": "Regular", + "amount": 1 + }, + { + "name": "Deborah Troesch", + "type": "Large Print", + "amount": 1 + }, + { + "name": "Frank Varricchio", + "type": "Teachers", + "amount": 1 + }, + { + "name": "Glenton Young", + "type": "Regular", + "amount": 1 + } + ] +} \ No newline at end of file diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index cfebdd5..0d4debb 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -15,3 +15,4 @@ pub mod smart_streaming; pub mod v2; pub mod backup; pub mod hymnal; +pub mod quarterlies; diff --git a/src/handlers/quarterlies.rs b/src/handlers/quarterlies.rs new file mode 100644 index 0000000..265e628 --- /dev/null +++ b/src/handlers/quarterlies.rs @@ -0,0 +1,126 @@ +use axum::{extract::{Path, State}, Json}; +use uuid::Uuid; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{Result, ApiError}, + models::{QuarterlyOrder, ApiResponse, CreateQuarterlyOrderRequest, UpdateQuarterlyOrderRequest}, + services::QuarterliesService, + utils::response::{success_response, success_with_message}, + AppState, +}; + +pub async fn list( + State(state): State, +) -> Result>>> { + let quarterlies = QuarterliesService::list_all(&state.pool).await?; + + Ok(success_response(quarterlies)) +} + +pub async fn get( + State(state): State, + Path(id): Path, +) -> Result>> { + let quarterly = QuarterliesService::get_by_id(&state.pool, &id).await?; + + match quarterly { + Some(q) => Ok(success_response(q)), + None => Err(ApiError::NotFound("Quarterly order not found".to_string())), + } +} + +pub async fn create( + State(state): State, + Json(req): Json, +) -> Result>> { + let quarterly = QuarterliesService::create(&state.pool, req).await?; + + Ok(success_with_message(quarterly, "Quarterly order created successfully")) +} + +pub async fn update( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>> { + let quarterly = QuarterliesService::update(&state.pool, &id, req).await?; + + match quarterly { + Some(q) => Ok(success_with_message(q, "Quarterly order updated successfully")), + None => Err(ApiError::NotFound("Quarterly order not found".to_string())), + } +} + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> Result>> { + let deleted = QuarterliesService::delete(&state.pool, &id).await?; + + if deleted { + Ok(success_with_message(true, "Quarterly order deleted successfully")) + } else { + Err(ApiError::NotFound("Quarterly order not found".to_string())) + } +} + +// Import structure for JSON import functionality +#[derive(Debug, Deserialize)] +pub struct ImportQuarterliesRequest { + pub orders: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ImportOrderItem { + pub name: String, + #[serde(rename = "type")] + pub quarterly_type: String, + pub amount: i32, +} + +#[derive(Debug, Serialize)] +pub struct ImportResult { + pub imported: usize, + pub skipped: usize, + pub errors: Vec, +} + +impl crate::utils::sanitize::SanitizeOutput for ImportResult { + fn sanitize_output(mut self) -> Self { + use crate::utils::sanitize::sanitize_string; + self.errors = self.errors.into_iter().map(sanitize_string).collect(); + self + } +} + +pub async fn import_from_json( + State(state): State, + Json(req): Json, +) -> Result>> { + let mut imported = 0; + let mut skipped = 0; + let mut errors = Vec::new(); + + for order in req.orders { + match QuarterliesService::create(&state.pool, CreateQuarterlyOrderRequest { + name: order.name.clone(), + quarterly_type: order.quarterly_type, + amount: order.amount, + }).await { + Ok(_) => imported += 1, + Err(e) => { + errors.push(format!("Failed to import '{}': {:?}", order.name, e)); + skipped += 1; + } + } + } + + let result = ImportResult { + imported, + skipped, + errors, + }; + + Ok(success_with_message(result, &format!("Import completed: {} imported, {} skipped", imported, skipped))) +} \ No newline at end of file diff --git a/src/handlers/schedule.rs b/src/handlers/schedule.rs index 1ae710a..042a15a 100644 --- a/src/handlers/schedule.rs +++ b/src/handlers/schedule.rs @@ -1,6 +1,6 @@ use axum::{extract::{Path, Query, State}, response::Json}; use crate::error::Result; -use crate::models::{ApiResponse, ScheduleData, ConferenceData, DateQuery}; +use crate::models::{ApiResponse, ConferenceData, DateQuery}; use crate::services::{ScheduleService, CreateScheduleRequest}; use crate::utils::response::{success_response, success_with_message, success_message_only}; use crate::AppState; @@ -8,11 +8,35 @@ use crate::AppState; pub async fn get_schedule( State(state): State, Query(params): Query, -) -> Result>> { - let date_str = params.date.unwrap_or_else(|| "2025-06-14".to_string()); - let schedule_data = ScheduleService::get_schedule_data_v1(&state.pool, &date_str).await?; - - Ok(success_response(schedule_data)) +) -> Result> { + match params.date { + Some(date_str) => { + // Return single schedule for specific date + let schedule_data = ScheduleService::get_schedule_data_v1(&state.pool, &date_str).await?; + Ok(Json(serde_json::json!({ + "success": true, + "data": schedule_data, + "message": null + }))) + }, + None => { + // Return all schedules when no date specified + let schedules = ScheduleService::list_schedules_v1(&state.pool).await?; + let mut schedule_data_list = Vec::new(); + + for schedule in schedules { + let date_str = schedule.date.format("%Y-%m-%d").to_string(); + let schedule_data = ScheduleService::get_schedule_data_v1(&state.pool, &date_str).await?; + schedule_data_list.push(schedule_data); + } + + Ok(Json(serde_json::json!({ + "success": true, + "data": schedule_data_list, + "message": null + }))) + } + } } pub async fn get_conference_data( diff --git a/src/main.rs b/src/main.rs index de8db0e..b436d41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,6 +96,10 @@ async fn main() -> Result<()> { .route("/members", get(handlers::members::list)) .route("/members", post(handlers::members::create)) .route("/members/:id", delete(handlers::members::delete)) + .route("/quarterlies", post(handlers::quarterlies::create)) + .route("/quarterlies/:id", put(handlers::quarterlies::update)) + .route("/quarterlies/:id", delete(handlers::quarterlies::delete)) + .route("/quarterlies/import", post(handlers::quarterlies::import_from_json)) .route("/backup/create", post(handlers::backup::create_backup)) .route("/backup/list", get(handlers::backup::list_backups)) .route("/backup/cleanup", post(handlers::backup::cleanup_backups)) @@ -125,6 +129,8 @@ let app = Router::new() .route("/api/schedule", get(handlers::schedule::get_schedule)) .route("/api/conference-data", get(handlers::schedule::get_conference_data)) .route("/api/members/active", get(handlers::members::list_active)) + .route("/api/quarterlies", get(handlers::quarterlies::list)) + .route("/api/quarterlies/:id", get(handlers::quarterlies::get)) // Hymnal API endpoints .route("/api/hymnals", get(handlers::hymnal::list_hymnals)) .route("/api/hymnals/:id", get(handlers::hymnal::get_hymnal)) diff --git a/src/models.rs b/src/models.rs index 13aee5f..89e7c3d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -246,6 +246,7 @@ pub struct Personnel { pub offering: String, pub special_music: String, pub speaker: String, + pub deacons: String, } #[derive(Debug, Serialize, Deserialize)] @@ -849,3 +850,36 @@ impl SanitizeOutput for SearchResult { self } } + +// Quarterly Models +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct QuarterlyOrder { + pub id: Uuid, + pub name: String, + pub quarterly_type: String, + pub amount: i32, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CreateQuarterlyOrderRequest { + pub name: String, + pub quarterly_type: String, + pub amount: i32, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateQuarterlyOrderRequest { + pub name: Option, + pub quarterly_type: Option, + pub amount: Option, +} + +impl SanitizeOutput for QuarterlyOrder { + fn sanitize_output(mut self) -> Self { + self.name = sanitize_string(self.name); + self.quarterly_type = sanitize_string(self.quarterly_type); + self + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index af8d4ec..f1f2880 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -14,6 +14,7 @@ pub mod backup_scheduler; pub mod hymnal; pub mod hymnal_search; pub mod members; +pub mod quarterlies; pub use events_v1::EventsV1Service; pub use events_v2::EventsV2Service; @@ -30,4 +31,5 @@ pub use thumbnail_generator::ThumbnailGenerator; pub use backup_scheduler::BackupScheduler; pub use hymnal::HymnalService; pub use hymnal_search::HymnalSearchService; -pub use members::MemberService; \ No newline at end of file +pub use members::MemberService; +pub use quarterlies::QuarterliesService; \ No newline at end of file diff --git a/src/services/quarterlies.rs b/src/services/quarterlies.rs new file mode 100644 index 0000000..38d39f3 --- /dev/null +++ b/src/services/quarterlies.rs @@ -0,0 +1,66 @@ +use sqlx::PgPool; +use uuid::Uuid; +use crate::{error::Result, models::{QuarterlyOrder, CreateQuarterlyOrderRequest, UpdateQuarterlyOrderRequest}, sql}; + +pub struct QuarterliesService; + +impl QuarterliesService { + /// List all quarterly orders + pub async fn list_all(pool: &PgPool) -> Result> { + sql::quarterlies::list_all(pool).await + } + + /// Get quarterly order by ID + pub async fn get_by_id(pool: &PgPool, id: &Uuid) -> Result> { + sql::quarterlies::get_by_id(pool, id).await + } + + /// Create new quarterly order with validation + pub async fn create(pool: &PgPool, req: CreateQuarterlyOrderRequest) -> Result { + // Validate quarterly type + let valid_types = vec!["Regular", "Teachers", "Large Print", "EG White Notes"]; + if !valid_types.contains(&req.quarterly_type.as_str()) { + return Err(crate::error::ApiError::BadRequest( + format!("Invalid quarterly type. Must be one of: {}", valid_types.join(", ")) + )); + } + + // Validate amount is positive + if req.amount <= 0 { + return Err(crate::error::ApiError::BadRequest( + "Amount must be greater than 0".to_string() + )); + } + + sql::quarterlies::create(pool, req).await + } + + /// Update quarterly order + pub async fn update(pool: &PgPool, id: &Uuid, req: UpdateQuarterlyOrderRequest) -> Result> { + // Validate quarterly type if provided + if let Some(ref quarterly_type) = req.quarterly_type { + let valid_types = vec!["Regular", "Teachers", "Large Print", "EG White Notes"]; + if !valid_types.contains(&quarterly_type.as_str()) { + return Err(crate::error::ApiError::BadRequest( + format!("Invalid quarterly type. Must be one of: {}", valid_types.join(", ")) + )); + } + } + + // Validate amount is positive if provided + if let Some(amount) = req.amount { + if amount <= 0 { + return Err(crate::error::ApiError::BadRequest( + "Amount must be greater than 0".to_string() + )); + } + } + + sql::quarterlies::update(pool, id, req).await + } + + /// Delete quarterly order by ID + pub async fn delete(pool: &PgPool, id: &Uuid) -> Result { + sql::quarterlies::delete(pool, id).await + } +} \ No newline at end of file diff --git a/src/services/schedule.rs b/src/services/schedule.rs index d68cf31..e4feebe 100644 --- a/src/services/schedule.rs +++ b/src/services/schedule.rs @@ -46,6 +46,7 @@ impl ScheduleService { offering: s.offering.unwrap_or_default(), special_music: s.special_music.unwrap_or_default(), speaker: s.sermon_speaker.unwrap_or_default(), + deacons: s.deacons.unwrap_or_default(), } } else { // Return empty data if no schedule found @@ -58,6 +59,7 @@ impl ScheduleService { offering: String::new(), special_music: String::new(), speaker: String::new(), + deacons: String::new(), } }; diff --git a/src/sql/mod.rs b/src/sql/mod.rs index 6bf3387..7a26de6 100644 --- a/src/sql/mod.rs +++ b/src/sql/mod.rs @@ -9,5 +9,6 @@ pub mod events; pub mod hymnal; pub mod media; pub mod members; +pub mod quarterlies; pub mod schedule; pub mod users; \ No newline at end of file diff --git a/src/sql/quarterlies.rs b/src/sql/quarterlies.rs new file mode 100644 index 0000000..d24d725 --- /dev/null +++ b/src/sql/quarterlies.rs @@ -0,0 +1,61 @@ +use sqlx::PgPool; +use uuid::Uuid; +use crate::{error::Result, models::{QuarterlyOrder, CreateQuarterlyOrderRequest, UpdateQuarterlyOrderRequest}}; + +pub async fn list_all(pool: &PgPool) -> Result> { + let quarterlies = sqlx::query_as::<_, QuarterlyOrder>( + "SELECT id, name, quarterly_type, amount, created_at, updated_at FROM quarterly_orders ORDER BY name ASC" + ) + .fetch_all(pool) + .await?; + + Ok(quarterlies) +} + +pub async fn get_by_id(pool: &PgPool, id: &Uuid) -> Result> { + let quarterly = sqlx::query_as::<_, QuarterlyOrder>( + "SELECT id, name, quarterly_type, amount, created_at, updated_at FROM quarterly_orders WHERE id = $1" + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(quarterly) +} + +pub async fn create(pool: &PgPool, req: CreateQuarterlyOrderRequest) -> Result { + let quarterly = sqlx::query_as::<_, QuarterlyOrder>( + "INSERT INTO quarterly_orders (id, name, quarterly_type, amount) VALUES ($1, $2, $3, $4) RETURNING id, name, quarterly_type, amount, created_at, updated_at" + ) + .bind(Uuid::new_v4()) + .bind(req.name) + .bind(req.quarterly_type) + .bind(req.amount) + .fetch_one(pool) + .await?; + + Ok(quarterly) +} + +pub async fn update(pool: &PgPool, id: &Uuid, req: UpdateQuarterlyOrderRequest) -> Result> { + let quarterly = sqlx::query_as::<_, QuarterlyOrder>( + "UPDATE quarterly_orders SET name = COALESCE($2, name), quarterly_type = COALESCE($3, quarterly_type), amount = COALESCE($4, amount), updated_at = NOW() WHERE id = $1 RETURNING id, name, quarterly_type, amount, created_at, updated_at" + ) + .bind(id) + .bind(req.name) + .bind(req.quarterly_type) + .bind(req.amount) + .fetch_optional(pool) + .await?; + + Ok(quarterly) +} + +pub async fn delete(pool: &PgPool, id: &Uuid) -> Result { + let result = sqlx::query("DELETE FROM quarterly_orders WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} \ No newline at end of file