Initial commit: Church Core Rust library
Some checks are pending
iOS UniFFI Build / build-ios (push) Waiting to run

Add church management API library with cross-platform support for iOS, Android, and WASM.
Features include event management, bulletin handling, contact forms, and authentication.
This commit is contained in:
RTSDA 2025-08-16 19:25:01 -04:00
commit 4d6b23beb3
66 changed files with 13067 additions and 0 deletions

38
.github/workflows/ios-build.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: iOS UniFFI Build
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-ios,x86_64-apple-ios,aarch64-apple-ios-sim
- name: Install uniffi_bindgen
run: cargo install uniffi_bindgen --bin uniffi-bindgen
- name: Build iOS framework
run: make ios
- name: Test Rust code
run: cargo test --features uniffi
- name: Upload iOS artifacts
uses: actions/upload-artifact@v4
with:
name: ios-bindings
path: |
bindings/ios/church_core.swift
bindings/ios/church_coreFFI.h
bindings/ios/church_coreFFI.modulemap
bindings/ios/libchurch_core_universal.a

43
.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
# Rust
/target/
Cargo.lock
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build artifacts
*.a
*.dylib
*.so
*.dll
# Logs
*.log
# Environment files
.env
.env.local
# Generated bindings (if you want to regenerate them)
church_core.swift
church_coreFFI.h
church_coreFFI.modulemap
libchurch_core_*.a
# Build directories
build/
dist/
# Test artifacts
coverage/
# Temporary files
*.tmp
*.temp

113
Cargo.toml Normal file
View file

@ -0,0 +1,113 @@
[package]
name = "church-core"
version = "0.1.0"
edition = "2021"
description = "Shared Rust crate for church application APIs and data models"
authors = ["Benjamin Slingo <benjamin@example.com>"]
license = "MIT"
[dependencies]
# HTTP client (using rustls to avoid OpenSSL cross-compilation issues)
reqwest = { version = "0.11", features = ["json", "multipart", "stream", "rustls-tls"], default-features = false }
tokio = { version = "1.0", features = ["full"] }
# JSON handling
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Date/time handling
chrono = { version = "0.4", features = ["serde"] }
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Caching and utilities
moka = { version = "0.12", features = ["future"] }
async-trait = "0.1"
rand = "0.8"
urlencoding = "2.1"
# UUID generation
uuid = { version = "1.0", features = ["v4", "serde"] }
# Base64 encoding for image caching
base64 = "0.21"
# URL handling
url = "2.4"
# Regular expressions
regex = "1.10"
# System calls for iOS device detection
libc = "0.2"
# HTML processing
html2text = "0.12"
# UniFFI for mobile bindings
uniffi = { version = "0.27", features = ["tokio"] }
# Build dependencies
[build-dependencies]
uniffi = { version = "0.27", features = ["build"], optional = true }
uniffi_bindgen = { version = "0.27", features = ["clap"] }
# Bin dependencies
[dependencies.uniffi_bindgen_dep]
package = "uniffi_bindgen"
version = "0.27"
optional = true
# Testing dependencies
[dev-dependencies]
tokio-test = "0.4"
mockito = "0.31"
serde_json = "1.0"
tempfile = "3.8"
pretty_assertions = "1.4"
# Optional FFI support
[dependencies.wasm-bindgen]
version = "0.2"
optional = true
[dependencies.wasm-bindgen-futures]
version = "0.4"
optional = true
[dependencies.js-sys]
version = "0.3"
optional = true
[dependencies.web-sys]
version = "0.3"
optional = true
features = [
"console",
"Window",
"Document",
"Element",
"HtmlElement",
"Storage",
"Request",
"RequestInit",
"Response",
"Headers",
]
[features]
default = ["native"]
native = []
wasm = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys"]
ffi = ["uniffi/tokio"]
uniffi = ["ffi", "uniffi/build"]
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[[bin]]
name = "church-core-test"
path = "src/bin/test.rs"

69
Makefile Normal file
View file

@ -0,0 +1,69 @@
# UniFFI iOS Build Makefile
# No Python dependencies required
.PHONY: all clean ios android wasm help install-deps
# Default target
all: ios
# Build iOS framework with UniFFI bindings
ios:
@echo "🚀 Building iOS framework..."
@./build_ios.sh
# Clean all build artifacts
clean:
@echo "🧹 Cleaning build artifacts..."
@rm -rf target/
@rm -rf bindings/
@rm -rf libchurch_core_universal.a
# Install required dependencies
install-deps:
@echo "📦 Installing build dependencies..."
@rustup target add aarch64-apple-ios
@rustup target add x86_64-apple-ios
@rustup target add aarch64-apple-ios-sim
@cargo install uniffi_bindgen --bin uniffi-bindgen
# Test the Rust library
test:
@echo "🧪 Running tests..."
@cargo test --features uniffi
# Build for development (faster, debug mode)
dev:
@echo "🔧 Building development version..."
@cargo build --features uniffi
# Generate only Swift bindings (no rebuild)
bindings-only:
@echo "⚡ Generating Swift bindings only..."
@mkdir -p bindings/ios
@uniffi-bindgen generate \
--library target/aarch64-apple-ios/release/libchurch_core.a \
--language swift \
--out-dir bindings/ios \
src/church_core.udl
@echo "📋 Creating module map..."
@echo 'module church_coreFFI {\n header "church_coreFFI.h"\n export *\n}' > bindings/ios/church_coreFFI.modulemap
# Copy generated files to iOS project
copy-to-ios:
@echo "📦 Copying files to iOS project..."
@cp bindings/ios/church_core.swift ../RTSDA-iOS/RTSDA/
@cp bindings/ios/church_coreFFI.h ../RTSDA-iOS/RTSDA/
@cp bindings/ios/church_coreFFI.modulemap ../RTSDA-iOS/RTSDA/
@cp bindings/ios/libchurch_core_universal.a ../RTSDA-iOS/RTSDA/libchurch_core.a
# Help
help:
@echo "📚 Available commands:"
@echo " make ios - Build complete iOS framework (default)"
@echo " make clean - Clean all build artifacts"
@echo " make install-deps - Install required build dependencies"
@echo " make test - Run Rust tests"
@echo " make dev - Quick development build"
@echo " make bindings-only - Generate Swift bindings only"
@echo " make copy-to-ios - Copy generated files to iOS project"
@echo " make help - Show this help message"

348
README.md Normal file
View file

@ -0,0 +1,348 @@
# Church Core
A shared Rust crate providing unified API access and data models for church applications across multiple platforms (iOS, Android, Web, Desktop).
## Overview
Church Core centralizes all church application logic, API communication, and data management into a single, well-tested Rust crate. This enables client applications to become "dumb display devices" while ensuring consistency across platforms.
## Features
- **Unified API Client**: Single interface for all church APIs
- **Consistent Data Models**: Shared types for events, bulletins, sermons, etc.
- **Cross-Platform Support**: Native bindings for iOS, Android, WASM, and FFI
- **Built-in Caching**: Automatic response caching with TTL
- **Offline Support**: Graceful offline operation with cached data
- **Authentication**: PocketBase and Jellyfin integration
- **Error Handling**: Comprehensive error types with retry logic
- **Type Safety**: Rust's type system prevents API mismatches
## Architecture
```
church-core/
├── src/
│ ├── client/ # HTTP client and API modules
│ │ ├── events.rs # Event operations
│ │ ├── bulletins.rs # Bulletin operations
│ │ ├── sermons.rs # Sermon operations
│ │ ├── contact.rs # Contact form handling
│ │ └── config.rs # Configuration management
│ ├── models/ # Data structures
│ │ ├── event.rs # Event models
│ │ ├── bulletin.rs # Bulletin models
│ │ ├── sermon.rs # Sermon models
│ │ ├── contact.rs # Contact models
│ │ └── auth.rs # Authentication models
│ ├── auth/ # Authentication modules
│ ├── cache/ # Caching system
│ ├── utils/ # Utility functions
│ └── error.rs # Error types
└── README.md
```
## Quick Start
### Basic Usage
```rust
use church_core::{ChurchApiClient, ChurchCoreConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create client with default configuration
let config = ChurchCoreConfig::default();
let client = ChurchApiClient::new(config)?;
// Get upcoming events
let events = client.get_upcoming_events(Some(10)).await?;
for event in events {
println!("{}: {}", event.title, event.start_time);
}
// Get current bulletin
if let Some(bulletin) = client.get_current_bulletin().await? {
println!("Current bulletin: {}", bulletin.title);
}
// Submit contact form
let contact = ContactForm::new(
"John Doe".to_string(),
"john@example.com".to_string(),
"Prayer Request".to_string(),
"Please pray for my family.".to_string()
);
let submission_id = client.submit_contact_form(contact).await?;
println!("Contact form submitted: {}", submission_id);
Ok(())
}
```
### Custom Configuration
```rust
use church_core::{ChurchApiClient, ChurchCoreConfig};
use std::time::Duration;
let config = ChurchCoreConfig::new()
.with_base_url("https://api.mychurch.org/api")
.with_cache_ttl(Duration::from_secs(600))
.with_timeout(Duration::from_secs(15))
.with_retry_attempts(5)
.with_offline_mode(true);
let client = ChurchApiClient::new(config)?;
```
## Data Models
### Event
```rust
use church_core::{Event, EventCategory, NewEvent};
use chrono::{DateTime, Utc};
// Create a new event
let new_event = NewEvent {
title: "Bible Study".to_string(),
description: "Weekly Bible study group".to_string(),
start_time: Utc::now(),
end_time: Utc::now() + chrono::Duration::hours(2),
location: "Fellowship Hall".to_string(),
location_url: Some("https://maps.google.com/...".to_string()),
category: EventCategory::Education,
is_featured: true,
// ... other fields
};
let event_id = client.create_event(new_event).await?;
```
### Bulletin
```rust
use church_core::{Bulletin, NewBulletin};
use chrono::NaiveDate;
// Get current bulletin
if let Some(bulletin) = client.get_current_bulletin().await? {
println!("Sabbath School: {}", bulletin.sabbath_school);
println!("Divine Worship: {}", bulletin.divine_worship);
// Check for announcements
for announcement in bulletin.active_announcements() {
println!("📢 {}: {}", announcement.title, announcement.content);
}
}
```
### Contact Form
```rust
use church_core::{ContactForm, ContactCategory, VisitorInfo};
let contact = ContactForm::new(
"Jane Smith".to_string(),
"jane@example.com".to_string(),
"New Visitor".to_string(),
"I'm interested in learning more about your church.".to_string()
)
.with_category(ContactCategory::Visitor)
.with_phone("555-123-4567".to_string())
.with_visitor_info(VisitorInfo {
is_first_time: true,
wants_follow_up: true,
wants_newsletter: true,
// ... other fields
});
let submission_id = client.submit_contact_form(contact).await?;
```
## Authentication
### PocketBase Authentication
```rust
use church_core::auth::{LoginRequest, AuthToken};
// Authenticate with PocketBase
let login = LoginRequest {
identity: "admin@church.org".to_string(),
password: "secure_password".to_string(),
};
// Note: Authentication methods would be implemented in auth module
// let token = client.authenticate_pocketbase(login).await?;
// client.set_auth_token(token).await;
```
## Caching
The client automatically caches responses with configurable TTL:
```rust
// Cache is automatically used
let events = client.get_upcoming_events(None).await?; // Fetches from API
let events = client.get_upcoming_events(None).await?; // Returns from cache
// Manual cache management
client.clear_cache().await;
let (cache_size, max_size) = client.get_cache_stats().await;
```
## Error Handling
```rust
use church_core::{ChurchApiError, Result};
match client.get_event("invalid-id").await {
Ok(Some(event)) => println!("Found event: {}", event.title),
Ok(None) => println!("Event not found"),
Err(ChurchApiError::NotFound) => println!("Event does not exist"),
Err(ChurchApiError::Network(_)) => println!("Network error - check connection"),
Err(ChurchApiError::Auth(_)) => println!("Authentication required"),
Err(e) => println!("Other error: {}", e),
}
```
## Platform Integration
### iOS (Swift)
```swift
// FFI bindings would be generated for iOS
import ChurchCore
let client = ChurchApiClient()
client.getUpcomingEvents(limit: 10) { events in
// Handle events
}
```
### Android (Kotlin/Java)
```kotlin
// JNI bindings for Android
import org.church.ChurchCore
val client = ChurchCore()
val events = client.getUpcomingEvents(10)
```
### Web (WASM)
```javascript
// WASM bindings for web
import { ChurchApiClient } from 'church-core-wasm';
const client = new ChurchApiClient();
const events = await client.getUpcomingEvents(10);
```
## Testing
Run the test binary to verify API connectivity:
```bash
cargo run --bin church-core-test
```
This will test:
- Health check endpoint
- Upcoming events retrieval
- Current bulletin fetching
- Configuration loading
- Cache statistics
## API Endpoints
The client interfaces with these standard endpoints:
- `GET /events/upcoming` - Get upcoming events
- `GET /events/{id}` - Get single event
- `POST /events` - Create event
- `GET /bulletins/current` - Get current bulletin
- `GET /bulletins` - List bulletins
- `GET /config` - Get church configuration
- `POST /contact` - Submit contact form
- `GET /sermons` - List sermons
- `GET /sermons/search` - Search sermons
## Development
### Building for iOS (No Python Required!)
```bash
# Quick build using Makefile
make ios
# Or use the build script directly
./build_ios.sh
# Install dependencies first time
make install-deps
# Development build (faster)
make dev
# Clean build artifacts
make clean
```
### Building Steps Explained
1. **Install iOS targets**: Adds Rust toolchains for iOS devices and simulators
2. **Build static libraries**: Creates `.a` files for each iOS architecture
3. **Create universal library**: Combines all architectures using `lipo`
4. **Generate Swift bindings**: Uses `uniffi-bindgen` to create Swift interfaces
5. **Copy to iOS project**: Moves all files to your Xcode project
Generated files:
- `church_core.swift` - Swift interface to your Rust code
- `church_coreFFI.h` - C header file
- `church_coreFFI.modulemap` - Module map for Swift
- `libchurch_core.a` - Universal static library
### Standard Rust Building
```bash
# Standard build
cargo build
# Build with WASM support
cargo build --features wasm
# Build with UniFFI support
cargo build --features uniffi
# Run tests
cargo test --features uniffi
```
### Features
- `default`: Native Rust client
- `wasm`: WebAssembly bindings
- `uniffi`: UniFFI bindings for iOS/Android (replaces old `ffi` feature)
- `native`: Native platform features
## License
MIT License - see LICENSE file for details.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Add tests for new functionality
4. Ensure all tests pass
5. Submit a pull request
## Support
For issues and questions:
- Check the API documentation
- Review existing GitHub issues
- Create a new issue with detailed information

66
RTSDA/Info.plist Normal file
View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_device.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libchurch_core_device.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_sim.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64-simulator</string>
<key>LibraryPath</key>
<string>libchurch_core_sim.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_mac_catalyst.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-maccatalyst</string>
<key>LibraryPath</key>
<string>libchurch_core_mac_catalyst.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>maccatalyst</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

View file

@ -0,0 +1,95 @@
# Church Core Android Bindings
This directory contains the generated Kotlin bindings for the church-core Rust crate.
## Files:
- `uniffi/church_core/` - Generated Kotlin bindings
## What's Missing:
- Native libraries (.so files) - You need to compile these with Android NDK
- JNI library structure - Will be created when you compile native libraries
## To Complete Android Setup:
### 1. Install Android Development Tools:
```bash
# Install Android SDK/NDK (via Android Studio or command line tools)
# Set environment variables:
export ANDROID_SDK_ROOT=/path/to/android/sdk
export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/[version]
```
### 2. Install cargo-ndk:
```bash
cargo install cargo-ndk
```
### 3. Build native libraries:
```bash
# From church-core directory
cargo ndk --target arm64-v8a --platform 21 build --release --features uniffi
cargo ndk --target armeabi-v7a --platform 21 build --release --features uniffi
cargo ndk --target x86_64 --platform 21 build --release --features uniffi
cargo ndk --target x86 --platform 21 build --release --features uniffi
```
### 4. Create JNI structure:
```bash
mkdir -p jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86}
cp target/aarch64-linux-android/release/libchurch_core.so jniLibs/arm64-v8a/
cp target/armv7-linux-androideabi/release/libchurch_core.so jniLibs/armeabi-v7a/
cp target/x86_64-linux-android/release/libchurch_core.so jniLibs/x86_64/
cp target/i686-linux-android/release/libchurch_core.so jniLibs/x86/
```
## Integration in Android Project:
### 1. Add JNA dependency to your `build.gradle`:
```gradle
implementation 'net.java.dev.jna:jna:5.13.0@aar'
```
### 2. Copy files to your Android project:
- Copy `uniffi/church_core/` to `src/main/java/`
- Copy `jniLibs/` to `src/main/`
### 3. Usage in Kotlin:
```kotlin
import uniffi.church_core.*
class ChurchRepository {
fun fetchEvents(): String {
return fetchEventsJson()
}
fun fetchSermons(): String {
return fetchSermonsJson()
}
fun fetchBulletins(): String {
return fetchBulletinsJson()
}
// All other functions from the UDL file are available
}
```
## Functions Available:
All functions defined in `src/church_core.udl` are available in Kotlin:
- `fetchEventsJson()`
- `fetchSermonsJson()`
- `fetchBulletinsJson()`
- `fetchBibleVerseJson(query: String)`
- `fetchRandomBibleVerseJson()`
- `submitContactV2Json(...)`
- `fetchCachedImageBase64(url: String)`
- `getOptimalStreamingUrl(mediaId: String)`
- `parseEventsFromJson(eventsJson: String)`
- `parseSermonsFromJson(sermonsJson: String)`
- And many more...
## Architecture Notes:
- All business logic is in Rust (networking, parsing, validation, etc.)
- Kotlin only handles UI and calls Rust functions
- Same RTSDA architecture as iOS version
- JSON responses from Rust, parse to data classes in Kotlin

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_device.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libchurch_core_device.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_mac_catalyst.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-maccatalyst</string>
<key>LibraryPath</key>
<string>libchurch_core_mac_catalyst.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>maccatalyst</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_sim.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64-simulator</string>
<key>LibraryPath</key>
<string>libchurch_core_sim.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

6
build.rs Normal file
View file

@ -0,0 +1,6 @@
fn main() {
#[cfg(feature = "uniffi")]
{
uniffi::generate_scaffolding("src/church_core.udl").unwrap();
}
}

32
build.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
set -e
echo "Building iOS framework with UniFFI..."
# Add iOS targets
rustup target add aarch64-apple-ios
rustup target add aarch64-apple-ios-sim
rustup target add x86_64-apple-ios
# Build for iOS targets
cargo build --release --target aarch64-apple-ios --features uniffi
cargo build --release --target aarch64-apple-ios-sim --features uniffi
cargo build --release --target x86_64-apple-ios --features uniffi
# Create universal library
mkdir -p target/universal
lipo -create \
target/aarch64-apple-ios/release/libchurch_core.a \
target/aarch64-apple-ios-sim/release/libchurch_core.a \
target/x86_64-apple-ios/release/libchurch_core.a \
-output target/universal/libchurch_core.a
# Generate Swift bindings using cargo (this should work!)
cargo run --features uniffi --bin uniffi-bindgen generate --language swift --out-dir target/universal src/church_core.udl
# Copy to iOS project
cp target/universal/church_core.swift ../RTSDA-iOS/RTSDA/
cp target/universal/church_coreFFI.h ../RTSDA-iOS/RTSDA/
cp target/universal/libchurch_core.a ../RTSDA-iOS/RTSDA/
echo "Done! Generated files copied to iOS project."

120
build_android.sh Executable file
View file

@ -0,0 +1,120 @@
#!/bin/bash
# UniFFI Android Build Script
# Generates Kotlin bindings and Android libraries
set -e
echo "🤖 Building UniFFI Android Library..."
# Clean previous Android builds
echo "🧹 Cleaning previous Android builds..."
rm -rf bindings/android/
mkdir -p bindings/android
# Install required tools
if ! command -v uniffi-bindgen &> /dev/null; then
echo "📦 Installing uniffi_bindgen..."
cargo install uniffi_bindgen --bin uniffi-bindgen
fi
if ! command -v cargo-ndk &> /dev/null; then
echo "📦 Installing cargo-ndk..."
cargo install cargo-ndk
fi
# Android targets for cargo-ndk
ANDROID_ABIS=(
"arm64-v8a" # ARM64 devices
"armeabi-v7a" # ARM32 devices
"x86_64" # x86_64 emulator
"x86" # x86 emulator
)
echo "🔧 Building Rust library for Android targets using cargo-ndk..."
for abi in "${ANDROID_ABIS[@]}"; do
echo "Building for $abi..."
cargo ndk --target $abi --platform 21 build --release --features uniffi
done
echo "⚡ Generating Kotlin bindings..."
# Find the first available library for binding generation
if [ -f "target/aarch64-linux-android/release/libchurch_core.so" ]; then
LIB_FILE="target/aarch64-linux-android/release/libchurch_core.so"
else
# Find any available library
LIB_FILE=$(find target -name "libchurch_core.so" -type f | head -1)
fi
# Generate Kotlin bindings using uniffi_bindgen
uniffi-bindgen generate \
src/church_core.udl \
--language kotlin \
--out-dir bindings/android \
--lib-file "$LIB_FILE"
echo "📁 Organizing Android libraries..."
# Create JNI library structure
mkdir -p bindings/android/jniLibs/arm64-v8a
mkdir -p bindings/android/jniLibs/armeabi-v7a
mkdir -p bindings/android/jniLibs/x86_64
mkdir -p bindings/android/jniLibs/x86
# Copy libraries to JNI structure (cargo-ndk puts them in different locations)
if [ -f "target/aarch64-linux-android/release/libchurch_core.so" ]; then
cp target/aarch64-linux-android/release/libchurch_core.so bindings/android/jniLibs/arm64-v8a/
fi
if [ -f "target/armv7-linux-androideabi/release/libchurch_core.so" ]; then
cp target/armv7-linux-androideabi/release/libchurch_core.so bindings/android/jniLibs/armeabi-v7a/
fi
if [ -f "target/x86_64-linux-android/release/libchurch_core.so" ]; then
cp target/x86_64-linux-android/release/libchurch_core.so bindings/android/jniLibs/x86_64/
fi
if [ -f "target/i686-linux-android/release/libchurch_core.so" ]; then
cp target/i686-linux-android/release/libchurch_core.so bindings/android/jniLibs/x86/
fi
echo "📦 Creating Android README..."
cat > bindings/android/README.md << EOF
# Church Core Android Bindings
This directory contains the generated Kotlin bindings and native libraries for the church-core Rust crate.
## Files:
- \`uniffi/\` - Generated Kotlin bindings
- \`jniLibs/\` - Native libraries for Android architectures
- \`arm64-v8a/\` - ARM64 devices (modern phones)
- \`armeabi-v7a/\` - ARM32 devices (older phones)
- \`x86_64/\` - x86_64 emulator
- \`x86/\` - x86 emulator
## Integration:
1. Copy the Kotlin files from \`uniffi/\` to your Android project's \`src/main/java/\` directory
2. Copy the \`jniLibs/\` directory to your Android project's \`src/main/\` directory
3. Add JNA dependency to your \`build.gradle\`:
```gradle
implementation 'net.java.dev.jna:jna:5.13.0@aar'
```
## Usage:
```kotlin
import uniffi.church_core.*
// Fetch events
val eventsJson = fetchEventsJson()
// Fetch sermons
val sermonsJson = fetchSermonsJson()
// All other functions from the UDL file are available
```
EOF
echo "✅ UniFFI Android library build complete!"
echo "📁 Generated files in bindings/android/:"
echo " - uniffi/ (Kotlin bindings)"
echo " - jniLibs/ (Native libraries for all Android architectures)"
echo " - README.md (Integration instructions)"
echo ""
echo "🎯 Ready for Android integration!"
echo "💡 Follow the README.md instructions to integrate into your Android project"

114
build_ios.sh Executable file
View file

@ -0,0 +1,114 @@
#!/bin/bash
# UniFFI iOS Build Script
# Generates Swift bindings and universal iOS framework without Python dependencies
set -e
echo "🔨 Building UniFFI iOS Framework..."
# Clean previous builds
echo "🧹 Cleaning previous builds..."
rm -rf target/
rm -rf bindings/
mkdir -p bindings/ios
# Install uniffi_bindgen if not present
if ! command -v uniffi-bindgen &> /dev/null; then
echo "📦 Installing uniffi_bindgen..."
cargo install uniffi_bindgen --bin uniffi-bindgen
fi
# iOS and Mac Catalyst targets
IOS_TARGETS=(
"aarch64-apple-ios" # iOS device (ARM64)
"aarch64-apple-ios-sim" # iOS simulator (Apple Silicon)
"aarch64-apple-ios-macabi" # Mac Catalyst (Apple Silicon)
"x86_64-apple-ios-macabi" # Mac Catalyst (Intel)
)
echo "📱 Installing iOS targets..."
for target in "${IOS_TARGETS[@]}"; do
rustup target add "$target"
done
echo "🔧 Building Rust library for iOS targets..."
for target in "${IOS_TARGETS[@]}"; do
echo "Building for $target..."
cargo build --release --target "$target" --features uniffi
done
echo "⚡ Generating Swift bindings..."
# Generate Swift bindings using uniffi_bindgen
uniffi-bindgen generate \
src/church_core.udl \
--language swift \
--out-dir bindings/ios \
--lib-file target/aarch64-apple-ios/release/libchurch_core.a
echo "📋 Generating module map..."
# Create module map for Swift integration
cat > bindings/ios/church_coreFFI.modulemap << EOF
module church_coreFFI {
header "church_coreFFI.h"
export *
}
EOF
echo "🔗 Preparing libraries..."
# Simulator library (Apple Silicon only)
cp target/aarch64-apple-ios-sim/release/libchurch_core.a bindings/ios/libchurch_core_sim.a
# Device library (ARM64 device)
cp target/aarch64-apple-ios/release/libchurch_core.a bindings/ios/libchurch_core_device.a
# Mac Catalyst libraries
cp target/aarch64-apple-ios-macabi/release/libchurch_core.a bindings/ios/libchurch_core_mac_arm64.a
cp target/x86_64-apple-ios-macabi/release/libchurch_core.a bindings/ios/libchurch_core_mac_x86_64.a
# Create universal Mac Catalyst library
lipo -create \
bindings/ios/libchurch_core_mac_arm64.a \
bindings/ios/libchurch_core_mac_x86_64.a \
-output bindings/ios/libchurch_core_mac_catalyst.a
echo "🔗 Creating XCFramework..."
# Create separate header directories for each library
mkdir -p bindings/ios/device-headers
mkdir -p bindings/ios/sim-headers
mkdir -p bindings/ios/mac-arm64-headers
mkdir -p bindings/ios/mac-x86_64-headers
cp bindings/ios/church_coreFFI.h bindings/ios/device-headers/
cp bindings/ios/church_coreFFI.h bindings/ios/sim-headers/
cp bindings/ios/church_coreFFI.h bindings/ios/mac-arm64-headers/
cp bindings/ios/church_coreFFI.h bindings/ios/mac-x86_64-headers/
# Create XCFramework with Mac Catalyst support
# Using universal Mac Catalyst library built from -macabi targets
xcodebuild -create-xcframework \
-library bindings/ios/libchurch_core_device.a \
-headers bindings/ios/device-headers \
-library bindings/ios/libchurch_core_sim.a \
-headers bindings/ios/sim-headers \
-library bindings/ios/libchurch_core_mac_catalyst.a \
-headers bindings/ios/mac-arm64-headers \
-output bindings/ios/ChurchCore.xcframework
echo "📦 Moving files to iOS project..."
# Copy generated files to iOS project
IOS_PROJECT_DIR="../RTSDA-iOS/RTSDA"
cp bindings/ios/church_core.swift "$IOS_PROJECT_DIR/"
cp bindings/ios/church_coreFFI.h "$IOS_PROJECT_DIR/"
cp bindings/ios/church_coreFFI.modulemap "$IOS_PROJECT_DIR/"
cp -r bindings/ios/ChurchCore.xcframework "$IOS_PROJECT_DIR/"
echo "✅ UniFFI iOS framework build complete!"
echo "📁 Generated files:"
echo " - church_core.swift (Swift bindings)"
echo " - church_coreFFI.h (C header)"
echo " - church_coreFFI.modulemap (Module map)"
echo " - ChurchCore.xcframework (Universal XCFramework)"
echo ""
echo "🎯 Files copied to: $IOS_PROJECT_DIR"
echo "💡 Add ChurchCore.xcframework to your Xcode project and you're good to go!"
echo "📱 Supports: iOS Device (ARM64), iOS Simulator (Apple Silicon), Mac Catalyst (Intel + Apple Silicon)"

124
build_ios_tvos.sh Executable file
View file

@ -0,0 +1,124 @@
#!/bin/bash
# UniFFI iOS and tvOS Build Script
# Generates Swift bindings and universal framework for both iOS and tvOS
set -e
echo "🔨 Building UniFFI iOS and tvOS Framework..."
# Clean previous builds
echo "🧹 Cleaning previous builds..."
rm -rf target/
rm -rf bindings/
mkdir -p bindings/ios
# Install nightly toolchain for tvOS support
echo "🌙 Installing nightly toolchain for tvOS support..."
rustup toolchain install nightly
rustup component add rust-std --toolchain nightly --target aarch64-apple-tvos
rustup component add rust-std --toolchain nightly --target aarch64-apple-tvos-sim
rustup component add rust-std --toolchain nightly --target x86_64-apple-tvos
# Install uniffi_bindgen if not present
if ! command -v uniffi-bindgen &> /dev/null; then
echo "📦 Installing uniffi_bindgen..."
cargo install uniffi_bindgen --bin uniffi-bindgen
fi
# iOS targets
IOS_TARGETS=(
"aarch64-apple-ios" # iOS device (ARM64)
"x86_64-apple-ios" # iOS simulator (Intel)
)
# tvOS targets
TVOS_TARGETS=(
"aarch64-apple-tvos" # tvOS device (ARM64)
"aarch64-apple-tvos-sim" # tvOS simulator (Apple Silicon)
"x86_64-apple-tvos" # tvOS simulator (Intel)
)
echo "📱 Installing iOS targets..."
for target in "${IOS_TARGETS[@]}"; do
rustup target add "$target"
done
echo "📺 Installing tvOS targets..."
for target in "${TVOS_TARGETS[@]}"; do
rustup target add "$target" --toolchain nightly
done
echo "🔧 Building Rust library for iOS targets..."
for target in "${IOS_TARGETS[@]}"; do
echo "Building for $target..."
cargo build --release --target "$target" --features uniffi
done
echo "🔧 Building Rust library for tvOS targets..."
for target in "${TVOS_TARGETS[@]}"; do
echo "Building for $target..."
cargo +nightly build --release --target "$target" --features uniffi
done
echo "🔗 Creating universal libraries..."
# Create iOS universal library
lipo -create \
target/aarch64-apple-ios/release/libchurch_core.a \
target/x86_64-apple-ios/release/libchurch_core.a \
-output bindings/ios/libchurch_core_ios.a
# Create tvOS universal library
lipo -create \
target/aarch64-apple-tvos/release/libchurch_core.a \
target/aarch64-apple-tvos-sim/release/libchurch_core.a \
target/x86_64-apple-tvos/release/libchurch_core.a \
-output bindings/ios/libchurch_core_tvos.a
# Create combined universal library for Xcode (it will pick the right slice)
lipo -create \
target/aarch64-apple-ios/release/libchurch_core.a \
target/x86_64-apple-ios/release/libchurch_core.a \
target/aarch64-apple-tvos/release/libchurch_core.a \
target/x86_64-apple-tvos/release/libchurch_core.a \
-output bindings/ios/libchurch_core_universal.a
echo "⚡ Generating Swift bindings..."
# Generate Swift bindings using uniffi_bindgen
uniffi-bindgen generate \
src/church_core.udl \
--language swift \
--out-dir bindings/ios \
--lib-file target/aarch64-apple-ios/release/libchurch_core.a
echo "📋 Generating module map..."
# Create module map for Swift integration
cat > bindings/ios/church_coreFFI.modulemap << EOF
module church_coreFFI {
header "church_coreFFI.h"
export *
}
EOF
echo "📦 Moving files to iOS project..."
# Copy generated files to iOS project
IOS_PROJECT_DIR="../RTSDA-iOS/RTSDA"
cp bindings/ios/church_core.swift "$IOS_PROJECT_DIR/"
cp bindings/ios/church_coreFFI.h "$IOS_PROJECT_DIR/"
cp bindings/ios/church_coreFFI.modulemap "$IOS_PROJECT_DIR/"
cp bindings/ios/libchurch_core_universal.a "$IOS_PROJECT_DIR/libchurch_core.a"
echo "✅ UniFFI iOS and tvOS framework build complete!"
echo "📁 Generated files:"
echo " - church_core.swift (Swift bindings)"
echo " - church_coreFFI.h (C header)"
echo " - church_coreFFI.modulemap (Module map)"
echo " - libchurch_core.a (Universal static library - iOS + tvOS)"
echo ""
echo "🎯 Files copied to: $IOS_PROJECT_DIR"
echo "💡 You can now build your iOS/tvOS project in Xcode!"
echo ""
echo "📺 Libraries also available separately:"
echo " - bindings/ios/libchurch_core_ios.a (iOS only)"
echo " - bindings/ios/libchurch_core_tvos.a (tvOS only)"

105
build_tvos_zbuild.sh Executable file
View file

@ -0,0 +1,105 @@
#!/bin/bash
# UniFFI iOS and tvOS Build Script using -Zbuild-std
# Uses nightly cargo with -Zbuild-std to build tvOS targets
set -e
echo "🔨 Building UniFFI iOS and tvOS Framework with -Zbuild-std..."
# Clean previous builds
echo "🧹 Cleaning previous builds..."
rm -rf target/
rm -rf bindings/
mkdir -p bindings/ios
# Install nightly toolchain
echo "🌙 Installing nightly toolchain..."
rustup toolchain install nightly
rustup component add rust-src --toolchain nightly
# Install uniffi_bindgen if not present
if ! command -v uniffi-bindgen &> /dev/null; then
echo "📦 Installing uniffi_bindgen..."
cargo install uniffi_bindgen --bin uniffi-bindgen
fi
# iOS targets (stable)
IOS_TARGETS=(
"aarch64-apple-ios" # iOS device (ARM64)
"x86_64-apple-ios" # iOS simulator (Intel)
)
# tvOS targets (require -Zbuild-std)
TVOS_TARGETS=(
"aarch64-apple-tvos" # tvOS device (ARM64)
"x86_64-apple-tvos" # tvOS simulator (Intel)
)
echo "📱 Installing iOS targets..."
for target in "${IOS_TARGETS[@]}"; do
rustup target add "$target"
done
echo "🔧 Building Rust library for iOS targets..."
for target in "${IOS_TARGETS[@]}"; do
echo "Building for $target..."
cargo build --release --target "$target" --features uniffi
done
echo "📺 Building Rust library for tvOS targets with -Zbuild-std..."
for target in "${TVOS_TARGETS[@]}"; do
echo "Building for $target..."
cargo +nightly build --release --target "$target" --features uniffi -Zbuild-std
done
echo "🔗 Creating separate libraries..."
# iOS universal library
lipo -create \
target/aarch64-apple-ios/release/libchurch_core.a \
target/x86_64-apple-ios/release/libchurch_core.a \
-output bindings/ios/libchurch_core_ios.a
# tvOS universal library
lipo -create \
target/aarch64-apple-tvos/release/libchurch_core.a \
target/x86_64-apple-tvos/release/libchurch_core.a \
-output bindings/ios/libchurch_core_tvos.a
# Default to iOS library
cp bindings/ios/libchurch_core_ios.a bindings/ios/libchurch_core_universal.a
echo "⚡ Generating Swift bindings..."
uniffi-bindgen generate \
src/church_core.udl \
--language swift \
--out-dir bindings/ios \
--lib-file target/aarch64-apple-ios/release/libchurch_core.a
echo "📋 Generating module map..."
cat > bindings/ios/church_coreFFI.modulemap << EOF
module church_coreFFI {
header "church_coreFFI.h"
export *
}
EOF
echo "📦 Moving files to iOS project..."
IOS_PROJECT_DIR="../RTSDA-iOS/RTSDA"
cp bindings/ios/church_core.swift "$IOS_PROJECT_DIR/"
cp bindings/ios/church_coreFFI.h "$IOS_PROJECT_DIR/"
cp bindings/ios/church_coreFFI.modulemap "$IOS_PROJECT_DIR/"
cp bindings/ios/libchurch_core_universal.a "$IOS_PROJECT_DIR/libchurch_core.a"
# Also copy the separate libraries for manual swapping if needed
cp bindings/ios/libchurch_core_ios.a "$IOS_PROJECT_DIR/"
cp bindings/ios/libchurch_core_tvos.a "$IOS_PROJECT_DIR/"
echo "✅ UniFFI iOS and tvOS framework build complete!"
echo "📁 Generated separate libraries:"
echo " - libchurch_core.a (default: iOS)"
echo " - libchurch_core_ios.a (iOS only)"
echo " - libchurch_core_tvos.a (tvOS only)"
echo "🎯 Files copied to: $IOS_PROJECT_DIR"
echo "💡 If tvOS build fails, manually swap to libchurch_core_tvos.a"

24
generate_bindings.rs Normal file
View file

@ -0,0 +1,24 @@
use std::path::Path;
use uniffi_bindgen::library_mode::generate_bindings;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let lib_path = "target/aarch64-apple-ios/release/libchurch_core.a";
let out_dir = Path::new("bindings/ios");
// Create output directory if it doesn't exist
std::fs::create_dir_all(out_dir)?;
// Generate Swift bindings
generate_bindings(
&uniffi_bindgen::bindings::swift::SwiftBindingGenerator,
lib_path,
None, // no UDL file
None, // default config
None, // no config file
out_dir,
false, // not a library mode
)?;
println!("Swift bindings generated in {:?}", out_dir);
Ok(())
}

144
generate_kotlin_bindings.sh Executable file
View file

@ -0,0 +1,144 @@
#!/bin/bash
# Generate Kotlin bindings only (without native compilation)
# This gives you the Kotlin interface code to use when you set up Android development
set -e
echo "🤖 Generating Kotlin bindings for Android..."
# Clean previous Android bindings
echo "🧹 Cleaning previous Android bindings..."
rm -rf bindings/android/
mkdir -p bindings/android
# Install uniffi_bindgen if not present
if ! command -v uniffi-bindgen &> /dev/null; then
echo "📦 Installing uniffi_bindgen..."
cargo install uniffi_bindgen --bin uniffi-bindgen
fi
echo "⚡ Generating Kotlin bindings..."
# Use an existing iOS library file for binding generation (they're compatible)
if [ -f "bindings/ios/libchurch_core_device.a" ]; then
LIB_FILE="bindings/ios/libchurch_core_device.a"
elif [ -f "target/aarch64-apple-ios/release/libchurch_core.a" ]; then
LIB_FILE="target/aarch64-apple-ios/release/libchurch_core.a"
else
echo "❌ No existing library found. Please run ./build_ios.sh first."
exit 1
fi
# Generate Kotlin bindings using uniffi_bindgen
uniffi-bindgen generate \
src/church_core.udl \
--language kotlin \
--out-dir bindings/android \
--lib-file "$LIB_FILE"
echo "📦 Creating Android integration README..."
cat > bindings/android/README.md << 'EOF'
# Church Core Android Bindings
This directory contains the generated Kotlin bindings for the church-core Rust crate.
## Files:
- `uniffi/church_core/` - Generated Kotlin bindings
## What's Missing:
- Native libraries (.so files) - You need to compile these with Android NDK
- JNI library structure - Will be created when you compile native libraries
## To Complete Android Setup:
### 1. Install Android Development Tools:
```bash
# Install Android SDK/NDK (via Android Studio or command line tools)
# Set environment variables:
export ANDROID_SDK_ROOT=/path/to/android/sdk
export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/[version]
```
### 2. Install cargo-ndk:
```bash
cargo install cargo-ndk
```
### 3. Build native libraries:
```bash
# From church-core directory
cargo ndk --target arm64-v8a --platform 21 build --release --features uniffi
cargo ndk --target armeabi-v7a --platform 21 build --release --features uniffi
cargo ndk --target x86_64 --platform 21 build --release --features uniffi
cargo ndk --target x86 --platform 21 build --release --features uniffi
```
### 4. Create JNI structure:
```bash
mkdir -p jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86}
cp target/aarch64-linux-android/release/libchurch_core.so jniLibs/arm64-v8a/
cp target/armv7-linux-androideabi/release/libchurch_core.so jniLibs/armeabi-v7a/
cp target/x86_64-linux-android/release/libchurch_core.so jniLibs/x86_64/
cp target/i686-linux-android/release/libchurch_core.so jniLibs/x86/
```
## Integration in Android Project:
### 1. Add JNA dependency to your `build.gradle`:
```gradle
implementation 'net.java.dev.jna:jna:5.13.0@aar'
```
### 2. Copy files to your Android project:
- Copy `uniffi/church_core/` to `src/main/java/`
- Copy `jniLibs/` to `src/main/`
### 3. Usage in Kotlin:
```kotlin
import uniffi.church_core.*
class ChurchRepository {
fun fetchEvents(): String {
return fetchEventsJson()
}
fun fetchSermons(): String {
return fetchSermonsJson()
}
fun fetchBulletins(): String {
return fetchBulletinsJson()
}
// All other functions from the UDL file are available
}
```
## Functions Available:
All functions defined in `src/church_core.udl` are available in Kotlin:
- `fetchEventsJson()`
- `fetchSermonsJson()`
- `fetchBulletinsJson()`
- `fetchBibleVerseJson(query: String)`
- `fetchRandomBibleVerseJson()`
- `submitContactV2Json(...)`
- `fetchCachedImageBase64(url: String)`
- `getOptimalStreamingUrl(mediaId: String)`
- `parseEventsFromJson(eventsJson: String)`
- `parseSermonsFromJson(sermonsJson: String)`
- And many more...
## Architecture Notes:
- All business logic is in Rust (networking, parsing, validation, etc.)
- Kotlin only handles UI and calls Rust functions
- Same RTSDA architecture as iOS version
- JSON responses from Rust, parse to data classes in Kotlin
EOF
echo "✅ Kotlin bindings generated!"
echo "📁 Generated files in bindings/android/:"
ls -la bindings/android/
echo ""
echo "📖 See bindings/android/README.md for integration instructions"
echo "💡 You'll need Android SDK/NDK to compile the native libraries"
echo "🎯 But the Kotlin interface code is ready to use!"

40
simple_build.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
set -e
echo "🔨 Simple UniFFI iOS build..."
# Add targets if needed
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# Build for all iOS targets
echo "Building for iOS..."
cargo build --release --target aarch64-apple-ios --features uniffi
cargo build --release --target aarch64-apple-ios-sim --features uniffi
cargo build --release --target x86_64-apple-ios --features uniffi
# Create fat binary (iOS device + Intel simulator, skip ARM sim for now)
echo "Creating universal library..."
lipo -create \
target/aarch64-apple-ios/release/libchurch_core.a \
target/x86_64-apple-ios/release/libchurch_core.a \
-output libchurch_core_universal.a
# Generate bindings directly with uniffi_bindgen
echo "Generating Swift bindings..."
uniffi-bindgen generate src/church_core.udl --language swift --out-dir .
# Copy everything to iOS project
echo "Copying to iOS project..."
cp church_core.swift ../RTSDA-iOS/RTSDA/
cp church_coreFFI.h ../RTSDA-iOS/RTSDA/
cp libchurch_core_universal.a ../RTSDA-iOS/RTSDA/libchurch_core.a
# Create modulemap
cat > ../RTSDA-iOS/RTSDA/church_coreFFI.modulemap << EOF
module church_coreFFI {
header "church_coreFFI.h"
export *
}
EOF
echo "✅ Done! Build your iOS app in Xcode."

4
src/auth/mod.rs Normal file
View file

@ -0,0 +1,4 @@
// Authentication modules placeholder
// This contains authentication implementations
pub use crate::models::AuthToken;

View file

@ -0,0 +1,36 @@
use church_core::{
client::{ChurchApiClient, events::submit_event},
models::EventSubmission,
config::ChurchCoreConfig,
};
#[tokio::main]
async fn main() {
let config = ChurchCoreConfig::new();
let client = ChurchApiClient::new(config).unwrap();
let submission = EventSubmission {
title: "Test Event".to_string(),
description: "Testing date submission".to_string(),
start_time: "2025-06-28T23:00".to_string(), // The problematic format
end_time: "2025-06-29T00:00".to_string(),
location: "Test Location".to_string(),
location_url: None,
category: "Other".to_string(),
is_featured: false,
recurring_type: None,
bulletin_week: None,
submitter_email: "test@example.com".to_string(),
};
println!("Testing date validation:");
println!("Can parse start_time: {}", submission.parse_start_time().is_some());
println!("Can parse end_time: {}", submission.parse_end_time().is_some());
println!("Validation passes: {}", submission.validate_times());
println!("\nAttempting to submit event...");
match submit_event(&client, submission).await {
Ok(id) => println!("✅ Success! Event ID: {}", id),
Err(e) => println!("❌ Error: {}", e),
}
}

94
src/bin/test.rs Normal file
View file

@ -0,0 +1,94 @@
use church_core::{ChurchApiClient, ChurchCoreConfig, DeviceCapabilities, StreamingCapability};
use chrono::TimeZone;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the client with default configuration
let config = ChurchCoreConfig::default();
let client = ChurchApiClient::new(config)?;
println!("Church Core API Client Test");
println!("==========================");
// Test health check
match client.health_check().await {
Ok(true) => println!("✅ Health check passed"),
Ok(false) => println!("❌ Health check failed"),
Err(e) => println!("❌ Health check error: {}", e),
}
// Test upcoming events
match client.get_upcoming_events(Some(5)).await {
Ok(events) => {
println!("✅ Retrieved {} upcoming events", events.len());
for event in events.iter().take(3) {
println!(" - {}: {}", event.title, event.start_time.format("%Y-%m-%d %H:%M"));
}
}
Err(e) => println!("❌ Failed to get events: {}", e),
}
// Test current bulletin
match client.get_current_bulletin().await {
Ok(Some(bulletin)) => {
println!("✅ Retrieved current bulletin: {}", bulletin.title);
}
Ok(None) => println!(" No current bulletin found"),
Err(e) => println!("❌ Failed to get bulletin: {}", e),
}
// Test configuration
match client.get_config().await {
Ok(config) => {
println!("✅ Retrieved church config");
if let Some(name) = &config.church_name {
println!(" Church: {}", name);
}
}
Err(e) => println!("❌ Failed to get config: {}", e),
}
// Test sermons
match client.get_recent_sermons(Some(5)).await {
Ok(sermons) => {
println!("✅ Retrieved {} recent sermons", sermons.len());
for sermon in sermons.iter().take(2) {
println!(" - {}: {}", sermon.title, sermon.speaker);
}
}
Err(e) => println!("❌ Failed to get sermons: {}", e),
}
// Test livestreams
match client.get_livestreams().await {
Ok(streams) => {
println!("✅ Retrieved {} livestream archives", streams.len());
for stream in streams.iter().take(2) {
println!(" - {}: {}", stream.title, stream.speaker);
}
}
Err(e) => println!("❌ Failed to get livestreams: {}", e),
}
// Test cache stats
let (cache_size, max_size) = client.get_cache_stats().await;
println!("📊 Cache: {}/{} items", cache_size, max_size);
// Test streaming URL generation
println!("\n🎬 Testing Streaming URLs:");
let media_id = "test-id-123";
let base_url = "https://api.rockvilletollandsda.church";
let av1_url = DeviceCapabilities::get_streaming_url(base_url, media_id, StreamingCapability::AV1);
println!(" AV1: {}", av1_url.url);
let hls_url = DeviceCapabilities::get_streaming_url(base_url, media_id, StreamingCapability::HLS);
println!(" HLS: {}", hls_url.url);
let optimal_url = DeviceCapabilities::get_optimal_streaming_url(base_url, media_id);
println!(" Optimal: {} ({:?})", optimal_url.url, optimal_url.capability);
println!("\nTest completed!");
Ok(())
}

339
src/cache/mod.rs vendored Normal file
View file

@ -0,0 +1,339 @@
use serde::{de::DeserializeOwned, Serialize, Deserialize};
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
path::PathBuf,
};
use tokio::sync::RwLock;
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedHttpResponse {
pub data: Vec<u8>,
pub content_type: String,
pub headers: HashMap<String, String>,
pub status_code: u16,
#[serde(with = "instant_serde")]
pub cached_at: Instant,
#[serde(with = "instant_serde")]
pub expires_at: Instant,
}
// Custom serializer for Instant (can't be serialized directly)
mod instant_serde {
use super::*;
use serde::{Deserializer, Serializer};
pub fn serialize<S>(instant: &Instant, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Convert to duration since app start - this is approximate but works for our use case
let duration_since_start = instant.elapsed();
serializer.serialize_u64(duration_since_start.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Instant, D::Error>
where
D: Deserializer<'de>,
{
let secs = <u64 as Deserialize>::deserialize(deserializer)?;
// For loaded items, set as if they were cached "now" minus the stored duration
// This isn't perfect but works for expiration checking
Ok(Instant::now() - Duration::from_secs(secs))
}
}
// Simplified cache interface - removed trait object complexity
// Each cache type will implement these methods directly
#[derive(Debug)]
struct CacheEntry {
data: Vec<u8>,
expires_at: Instant,
}
impl CacheEntry {
fn new(data: Vec<u8>, ttl: Duration) -> Self {
Self {
data,
expires_at: Instant::now() + ttl,
}
}
fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
}
pub struct MemoryCache {
store: Arc<RwLock<HashMap<String, CacheEntry>>>,
http_store: Arc<RwLock<HashMap<String, CachedHttpResponse>>>,
max_size: usize,
cache_dir: Option<PathBuf>,
}
impl MemoryCache {
pub fn new(max_size: usize) -> Self {
Self {
store: Arc::new(RwLock::new(HashMap::new())),
http_store: Arc::new(RwLock::new(HashMap::new())),
max_size,
cache_dir: None,
}
}
pub fn with_disk_cache(mut self, cache_dir: PathBuf) -> Self {
self.cache_dir = Some(cache_dir);
self
}
fn get_cache_file_path(&self, url: &str) -> Option<PathBuf> {
self.cache_dir.as_ref().map(|dir| {
// Create a safe filename from URL
let hash = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
hasher.finish()
};
dir.join(format!("cache_{}.json", hash))
})
}
async fn cleanup_expired(&self) {
let mut store = self.store.write().await;
let now = Instant::now();
store.retain(|_, entry| entry.expires_at > now);
}
async fn ensure_capacity(&self) {
let mut store = self.store.write().await;
if store.len() >= self.max_size {
// Remove oldest entries if we're at capacity
// Collect keys to remove to avoid borrow issues
let mut to_remove: Vec<String> = Vec::new();
{
let entries: Vec<_> = store.iter().collect();
let mut sorted_entries = entries;
sorted_entries.sort_by_key(|(_, entry)| entry.expires_at);
let remove_count = sorted_entries.len().saturating_sub(self.max_size / 2);
for (key, _) in sorted_entries.into_iter().take(remove_count) {
to_remove.push(key.clone());
}
}
// Now remove the keys
for key in to_remove {
store.remove(&key);
}
}
}
}
impl MemoryCache {
pub async fn get<T>(&self, key: &str) -> Option<T>
where
T: DeserializeOwned + Send + 'static,
{
// Clean up expired entries periodically
if rand::random::<f32>() < 0.1 {
self.cleanup_expired().await;
}
let store = self.store.read().await;
if let Some(entry) = store.get(key) {
if !entry.is_expired() {
if let Ok(value) = serde_json::from_slice(&entry.data) {
return Some(value);
}
}
}
None
}
pub async fn set<T>(&self, key: &str, value: &T, ttl: Duration)
where
T: Serialize + Send + Sync,
{
if let Ok(data) = serde_json::to_vec(value) {
self.ensure_capacity().await;
let mut store = self.store.write().await;
store.insert(key.to_string(), CacheEntry::new(data, ttl));
}
}
pub async fn remove(&self, key: &str) {
let mut store = self.store.write().await;
store.remove(key);
}
pub async fn clear(&self) {
let mut store = self.store.write().await;
store.clear();
}
pub async fn len(&self) -> usize {
let store = self.store.read().await;
store.len()
}
pub async fn invalidate_prefix(&self, prefix: &str) {
let mut store = self.store.write().await;
store.retain(|key, _| !key.starts_with(prefix));
let mut http_store = self.http_store.write().await;
http_store.retain(|key, _| !key.starts_with(prefix));
}
// HTTP Response Caching Methods
pub async fn get_http_response(&self, url: &str) -> Option<CachedHttpResponse> {
// Clean up expired entries periodically
if rand::random::<f32>() < 0.1 {
self.cleanup_expired_http().await;
}
// 1. Check memory cache first (fastest)
{
let store = self.http_store.read().await;
println!("🔍 Memory cache lookup for: {}", url);
println!("🔍 Memory cache has {} entries", store.len());
if let Some(response) = store.get(url) {
if !response.is_expired() {
println!("🔍 Memory cache HIT - found valid entry");
return Some(response.clone());
} else {
println!("🔍 Memory cache entry expired");
}
}
}
// 2. Check disk cache (persistent)
if let Some(cache_path) = self.get_cache_file_path(url) {
println!("🔍 Checking disk cache at: {:?}", cache_path);
if let Ok(file_content) = fs::read(&cache_path).await {
if let Ok(cached_response) = serde_json::from_slice::<CachedHttpResponse>(&file_content) {
if !cached_response.is_expired() {
println!("🔍 Disk cache HIT - loading into memory");
// Load back into memory cache for faster future access
let mut store = self.http_store.write().await;
store.insert(url.to_string(), cached_response.clone());
return Some(cached_response);
} else {
println!("🔍 Disk cache entry expired, removing file");
let _ = fs::remove_file(&cache_path).await;
}
} else {
println!("🔍 Failed to parse disk cache file");
}
} else {
println!("🔍 No disk cache file found");
}
}
println!("🔍 Cache MISS - no valid entry found");
None
}
pub async fn set_http_response(&self, url: &str, response: CachedHttpResponse) {
self.ensure_http_capacity().await;
// Store in memory cache
let mut store = self.http_store.write().await;
println!("🔍 Storing in memory cache: {}", url);
println!("🔍 Memory cache will have {} entries after insert", store.len() + 1);
store.insert(url.to_string(), response.clone());
drop(store); // Release the lock before async disk operation
// Store in disk cache (async, non-blocking)
if let Some(cache_path) = self.get_cache_file_path(url) {
println!("🔍 Storing to disk cache: {:?}", cache_path);
// Ensure cache directory exists
if let Some(parent) = cache_path.parent() {
let _ = fs::create_dir_all(parent).await;
}
// Serialize and save to disk
match serde_json::to_vec(&response) {
Ok(serialized) => {
if let Err(e) = fs::write(&cache_path, serialized).await {
println!("🔍 Failed to write disk cache: {}", e);
} else {
println!("🔍 Successfully saved to disk cache");
}
}
Err(e) => {
println!("🔍 Failed to serialize for disk cache: {}", e);
}
}
}
}
async fn cleanup_expired_http(&self) {
let mut store = self.http_store.write().await;
let now = Instant::now();
store.retain(|_, response| response.expires_at > now);
}
async fn ensure_http_capacity(&self) {
let mut store = self.http_store.write().await;
if store.len() >= self.max_size {
// Remove oldest entries if we're at capacity
let mut to_remove: Vec<String> = Vec::new();
{
let entries: Vec<_> = store.iter().collect();
let mut sorted_entries = entries;
sorted_entries.sort_by_key(|(_, response)| response.cached_at);
let remove_count = sorted_entries.len().saturating_sub(self.max_size / 2);
for (key, _) in sorted_entries.into_iter().take(remove_count) {
to_remove.push(key.clone());
}
}
// Now remove the keys
for key in to_remove {
store.remove(&key);
}
}
}
}
impl CachedHttpResponse {
pub fn new(
data: Vec<u8>,
content_type: String,
headers: HashMap<String, String>,
status_code: u16,
ttl: Duration
) -> Self {
let now = Instant::now();
Self {
data,
content_type,
headers,
status_code,
cached_at: now,
expires_at: now + ttl,
}
}
pub fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
}
// Add rand dependency for periodic cleanup
// This is a simple implementation - in production you might want to use a more sophisticated cache like moka

76
src/church_core.udl Normal file
View file

@ -0,0 +1,76 @@
namespace church_core {
string fetch_events_json();
string fetch_bulletins_json();
string fetch_sermons_json();
string fetch_bible_verse_json(string query);
string fetch_random_bible_verse_json();
string fetch_scripture_verses_for_sermon_json(string sermon_id);
string fetch_config_json();
string fetch_current_bulletin_json();
string fetch_featured_events_json();
string fetch_stream_status_json();
boolean get_stream_live_status();
string get_livestream_url();
string fetch_live_stream_json();
string fetch_livestream_archive_json();
string submit_contact_json(string name, string email, string message);
string submit_contact_v2_json(string name, string email, string subject, string message, string phone);
string submit_contact_v2_json_legacy(string first_name, string last_name, string email, string subject, string message);
string fetch_cached_image_base64(string url);
string get_optimal_streaming_url(string media_id);
boolean device_supports_av1();
string get_av1_streaming_url(string media_id);
string get_hls_streaming_url(string media_id);
// Scripture formatting utilities
string format_scripture_text_json(string scripture_text);
string extract_scripture_references_string(string scripture_text);
string create_sermon_share_items_json(string title, string speaker, string? video_url, string? audio_url);
// Form validation functions
string validate_contact_form_json(string form_json);
boolean validate_email_address(string email);
boolean validate_phone_number(string phone);
// Event formatting functions
string format_event_for_display_json(string event_json);
string format_time_range_string(string start_time, string end_time);
boolean is_multi_day_event_check(string date);
// Home feed aggregation
string generate_home_feed_json(string events_json, string sermons_json, string bulletins_json, string verse_json);
// Media type management
string get_media_type_display_name(string media_type_str);
string get_media_type_icon(string media_type_str);
string filter_sermons_by_media_type(string sermons_json, string media_type_str);
// Individual config getter functions (RTSDA architecture compliant)
string get_church_name();
string get_contact_phone();
string get_contact_email();
string get_brand_color();
string get_about_text();
string get_donation_url();
string get_church_address();
sequence<f64> get_coordinates();
string get_website_url();
string get_facebook_url();
string get_youtube_url();
string get_instagram_url();
string get_mission_statement();
// Calendar event parsing (RTSDA architecture compliant)
string create_calendar_event_data(string event_json);
// JSON parsing functions (RTSDA architecture compliance)
string parse_events_from_json(string events_json);
string parse_sermons_from_json(string sermons_json);
string parse_bulletins_from_json(string bulletins_json);
string parse_bible_verse_from_json(string verse_json);
string parse_contact_result_from_json(string result_json);
string generate_verse_description(string verses_json);
string extract_full_verse_text(string verses_json);
string extract_stream_url_from_status(string status_json);
string parse_calendar_event_data(string calendar_json);
};

109
src/client/admin.rs Normal file
View file

@ -0,0 +1,109 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{
NewBulletin, BulletinUpdate,
NewEvent, EventUpdate, PendingEvent,
User, Schedule, NewSchedule, ScheduleUpdate,
ApiVersion,
},
};
// Admin Bulletin Management
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
client.post_api_with_version("/admin/bulletins", &bulletin, ApiVersion::V1).await
}
pub async fn update_bulletin(client: &ChurchApiClient, id: &str, update: BulletinUpdate) -> Result<()> {
let path = format!("/admin/bulletins/{}", id);
client.put_api(&path, &update).await
}
pub async fn delete_bulletin(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/bulletins/{}", id);
client.delete_api(&path).await
}
// Admin Event Management
pub async fn create_admin_event(client: &ChurchApiClient, event: NewEvent) -> Result<String> {
client.post_api_with_version("/admin/events", &event, ApiVersion::V1).await
}
pub async fn update_admin_event(client: &ChurchApiClient, id: &str, update: EventUpdate) -> Result<()> {
let path = format!("/admin/events/{}", id);
client.put_api(&path, &update).await
}
pub async fn delete_admin_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/{}", id);
client.delete_api(&path).await
}
// Admin Pending Events Management
pub async fn get_pending_events(client: &ChurchApiClient) -> Result<Vec<PendingEvent>> {
client.get_api("/admin/events/pending").await
}
pub async fn approve_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/pending/{}/approve", id);
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
if response.success {
Ok(())
} else {
Err(crate::error::ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Failed to approve pending event".to_string())
))
}
}
pub async fn reject_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/pending/{}/reject", id);
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
if response.success {
Ok(())
} else {
Err(crate::error::ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Failed to reject pending event".to_string())
))
}
}
pub async fn delete_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/pending/{}", id);
client.delete_api(&path).await
}
// Admin User Management
pub async fn get_users(client: &ChurchApiClient) -> Result<Vec<User>> {
client.get_api("/admin/users").await
}
// Admin Schedule Management
pub async fn create_schedule(client: &ChurchApiClient, schedule: NewSchedule) -> Result<String> {
client.post_api("/admin/schedule", &schedule).await
}
pub async fn update_schedule(client: &ChurchApiClient, date: &str, update: ScheduleUpdate) -> Result<()> {
let path = format!("/admin/schedule/{}", date);
client.put_api(&path, &update).await
}
pub async fn delete_schedule(client: &ChurchApiClient, date: &str) -> Result<()> {
let path = format!("/admin/schedule/{}", date);
client.delete_api(&path).await
}
pub async fn get_all_schedules(client: &ChurchApiClient) -> Result<Vec<Schedule>> {
client.get_api("/admin/schedule").await
}
// Admin Config Management
pub async fn get_admin_config(client: &ChurchApiClient) -> Result<crate::models::ChurchConfig> {
client.get_api("/admin/config").await
}

169
src/client/bible.rs Normal file
View file

@ -0,0 +1,169 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{BibleVerse, VerseOfTheDay, VerseCategory, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn get_random_verse(client: &ChurchApiClient) -> Result<BibleVerse> {
// The response format is {success: bool, data: Verse}
#[derive(serde::Deserialize, serde::Serialize)]
struct VerseResponse {
success: bool,
data: ApiVerse,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct ApiVerse {
id: String,
reference: String,
text: String,
#[serde(rename = "is_active")]
is_active: bool,
}
let url = client.build_url("/bible_verses/random");
let raw_response = client.client.get(&url).send().await?;
let response_text = raw_response.text().await?;
let response: VerseResponse = serde_json::from_str(&response_text)
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
if response.success {
Ok(BibleVerse::new(response.data.text, response.data.reference))
} else {
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
}
}
pub async fn get_verse_of_the_day(client: &ChurchApiClient) -> Result<VerseOfTheDay> {
client.get_api("/bible/verse-of-the-day").await
}
pub async fn get_verse_by_reference(client: &ChurchApiClient, reference: &str) -> Result<Option<BibleVerse>> {
let path = format!("/bible/verse?reference={}", urlencoding::encode(reference));
match client.get_api(&path).await {
Ok(verse) => Ok(Some(verse)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_verses_by_category(client: &ChurchApiClient, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
let mut path = format!("/bible/category/{}", category.display_name().to_lowercase());
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list(&path).await?;
Ok(response.data.items)
}
pub async fn search_verses(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
if let Some(limit) = limit {
path.push_str(&format!("&limit={}", limit));
}
// The bible_verses/search endpoint returns a custom format with additional fields
#[derive(serde::Deserialize, serde::Serialize)]
struct ApiBibleVerse {
id: String,
reference: String,
text: String,
is_active: bool,
created_at: String,
updated_at: String,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct BibleSearchResponse {
success: bool,
data: Vec<ApiBibleVerse>,
message: Option<String>,
}
let url = client.build_url(&path);
let raw_response = client.client.get(&url).send().await?;
let response_text = raw_response.text().await?;
let response: BibleSearchResponse = serde_json::from_str(&response_text)
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
if response.success {
// Convert ApiBibleVerse to BibleVerse
let verses = response.data.into_iter()
.map(|api_verse| BibleVerse::new(api_verse.text, api_verse.reference))
.collect();
Ok(verses)
} else {
Ok(Vec::new())
}
}
// V2 API methods
pub async fn get_random_verse_v2(client: &ChurchApiClient) -> Result<BibleVerse> {
#[derive(serde::Deserialize, serde::Serialize)]
struct VerseResponse {
success: bool,
data: ApiVerse,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct ApiVerse {
id: String,
reference: String,
text: String,
#[serde(rename = "is_active")]
is_active: bool,
}
let url = client.build_url_with_version("/bible_verses/random", ApiVersion::V2);
let raw_response = client.client.get(&url).send().await?;
let response_text = raw_response.text().await?;
let response: VerseResponse = serde_json::from_str(&response_text)
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
if response.success {
Ok(BibleVerse::new(response.data.text, response.data.reference))
} else {
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
}
}
pub async fn get_bible_verses_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
let mut path = "/bible_verses".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list_with_version(&path, ApiVersion::V2).await
}
pub async fn search_verses_v2(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
if let Some(limit) = limit {
path.push_str(&format!("&limit={}", limit));
}
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list_with_version(&path, ApiVersion::V2).await?;
Ok(response.data.items)
}

101
src/client/bulletins.rs Normal file
View file

@ -0,0 +1,101 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{Bulletin, NewBulletin, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn get_bulletins(client: &ChurchApiClient, active_only: bool) -> Result<Vec<Bulletin>> {
let path = if active_only {
"/bulletins?active=true"
} else {
"/bulletins"
};
let response: ApiListResponse<Bulletin> = client.get_api_list(path).await?;
Ok(response.data.items)
}
pub async fn get_current_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api("/bulletins/current").await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_bulletin(client: &ChurchApiClient, id: &str) -> Result<Option<Bulletin>> {
let path = format!("/bulletins/{}", id);
match client.get_api(&path).await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
client.post_api("/bulletins", &bulletin).await
}
pub async fn get_next_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api("/bulletins/next").await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
// V2 API methods
pub async fn get_bulletins_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
let mut path = "/bulletins".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
let url = client.build_url_with_version(&path, ApiVersion::V2);
let response: ApiListResponse<Bulletin> = client.get(&url).await?;
if response.success {
Ok(response)
} else {
Err(crate::error::ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub async fn get_current_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api_with_version("/bulletins/current", ApiVersion::V2).await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_next_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api_with_version("/bulletins/next", ApiVersion::V2).await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}

50
src/client/config.rs Normal file
View file

@ -0,0 +1,50 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{ChurchConfig, Schedule, ConferenceData, ApiVersion},
};
pub async fn get_config(client: &ChurchApiClient) -> Result<ChurchConfig> {
client.get("/config").await
}
pub async fn get_config_by_id(client: &ChurchApiClient, record_id: &str) -> Result<ChurchConfig> {
let path = format!("/config/records/{}", record_id);
client.get_api(&path).await
}
pub async fn update_config(client: &ChurchApiClient, config: ChurchConfig) -> Result<()> {
client.put_api("/config", &config).await
}
// V2 API methods
pub async fn get_config_v2(client: &ChurchApiClient) -> Result<ChurchConfig> {
client.get_api_with_version("/config", ApiVersion::V2).await
}
// Schedule endpoints
pub async fn get_schedule(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
let path = if let Some(date) = date {
format!("/schedule?date={}", date)
} else {
"/schedule".to_string()
};
client.get_api(&path).await
}
pub async fn get_schedule_v2(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
let path = if let Some(date) = date {
format!("/schedule?date={}", date)
} else {
"/schedule".to_string()
};
client.get_api_with_version(&path, ApiVersion::V2).await
}
pub async fn get_conference_data(client: &ChurchApiClient) -> Result<ConferenceData> {
client.get_api("/conference-data").await
}
pub async fn get_conference_data_v2(client: &ChurchApiClient) -> Result<ConferenceData> {
client.get_api_with_version("/conference-data", ApiVersion::V2).await
}

125
src/client/contact.rs Normal file
View file

@ -0,0 +1,125 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{ContactForm, ContactSubmission, ContactStatus, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn submit_contact_form(client: &ChurchApiClient, form: ContactForm) -> Result<String> {
// Create payload matching the expected format from iOS app
let payload = serde_json::json!({
"first_name": form.name.split_whitespace().next().unwrap_or(&form.name),
"last_name": form.name.split_whitespace().nth(1).unwrap_or(""),
"email": form.email,
"phone": form.phone.unwrap_or_default(),
"message": form.message
});
// Use the main API subdomain for consistency
let contact_url = client.build_url("/contact");
let response = client.client
.post(contact_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if response.status().is_success() {
Ok("Contact form submitted successfully".to_string())
} else {
Err(crate::error::ChurchApiError::Api(format!("Contact form submission failed with status: {}", response.status())))
}
}
pub async fn get_contact_submissions(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<ContactSubmission>> {
let mut path = "/contact/submissions".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list(&path).await
}
pub async fn get_contact_submission(client: &ChurchApiClient, id: &str) -> Result<Option<ContactSubmission>> {
let path = format!("/contact/submissions/{}", id);
match client.get_api(&path).await {
Ok(submission) => Ok(Some(submission)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn update_contact_submission(
client: &ChurchApiClient,
id: &str,
status: ContactStatus,
response: Option<String>
) -> Result<()> {
let path = format!("/contact/submissions/{}", id);
let update_data = serde_json::json!({
"status": status,
"response": response
});
client.put_api(&path, &update_data).await
}
// V2 API methods
pub async fn submit_contact_form_v2(client: &ChurchApiClient, form: ContactForm) -> Result<String> {
let mut payload = serde_json::json!({
"name": form.name,
"email": form.email,
"subject": form.subject,
"message": form.message
});
// Add phone field if provided
if let Some(phone) = &form.phone {
if !phone.trim().is_empty() {
payload["phone"] = serde_json::json!(phone);
}
}
let url = client.build_url_with_version("/contact", ApiVersion::V2);
let response = client.client
.post(url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if response.status().is_success() {
Ok("Contact form submitted successfully".to_string())
} else {
Err(crate::error::ChurchApiError::Api(format!("Contact form submission failed with status: {}", response.status())))
}
}

192
src/client/events.rs Normal file
View file

@ -0,0 +1,192 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{Event, NewEvent, EventUpdate, EventSubmission, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn get_events(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
let mut path = "/events".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list(&path).await
}
pub async fn get_upcoming_events(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/upcoming".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
pub async fn get_event(client: &ChurchApiClient, id: &str) -> Result<Option<Event>> {
let path = format!("/events/{}", id);
match client.get_api(&path).await {
Ok(event) => Ok(Some(event)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn create_event(client: &ChurchApiClient, event: NewEvent) -> Result<String> {
client.post_api("/events", &event).await
}
pub async fn update_event(client: &ChurchApiClient, id: &str, update: EventUpdate) -> Result<()> {
let path = format!("/events/{}", id);
client.put_api(&path, &update).await
}
pub async fn delete_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/events/{}", id);
client.delete_api(&path).await
}
pub async fn get_featured_events(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/featured".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
pub async fn get_events_by_category(client: &ChurchApiClient, category: &str, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = format!("/events/category/{}", category);
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
pub async fn get_events_by_date_range(
client: &ChurchApiClient,
start_date: &str,
end_date: &str
) -> Result<Vec<Event>> {
let path = format!("/events/range?start={}&end={}",
urlencoding::encode(start_date),
urlencoding::encode(end_date)
);
client.get_api(&path).await
}
pub async fn search_events(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = format!("/events/search?q={}", urlencoding::encode(query));
if let Some(limit) = limit {
path.push_str(&format!("&limit={}", limit));
}
client.get_api(&path).await
}
pub async fn upload_event_image(client: &ChurchApiClient, event_id: &str, image_data: Vec<u8>, filename: String) -> Result<String> {
let path = format!("/events/{}/image", event_id);
client.upload_file(&path, image_data, filename, "image".to_string()).await
}
// V2 API methods
pub async fn get_events_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
let mut path = "/events".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list_with_version(&path, ApiVersion::V2).await
}
pub async fn get_upcoming_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/upcoming".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api_with_version(&path, ApiVersion::V2).await
}
pub async fn get_featured_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/featured".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api_with_version(&path, ApiVersion::V2).await
}
pub async fn get_event_v2(client: &ChurchApiClient, id: &str) -> Result<Option<Event>> {
let path = format!("/events/{}", id);
match client.get_api_with_version(&path, ApiVersion::V2).await {
Ok(event) => Ok(Some(event)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn submit_event(client: &ChurchApiClient, submission: EventSubmission) -> Result<String> {
client.post_api("/events/submit", &submission).await
}

402
src/client/http.rs Normal file
View file

@ -0,0 +1,402 @@
use crate::{
client::ChurchApiClient,
error::{ChurchApiError, Result},
models::{ApiResponse, ApiListResponse, ApiVersion},
cache::CachedHttpResponse,
};
use serde::{de::DeserializeOwned, Serialize};
use std::{collections::HashMap, time::Duration};
impl ChurchApiClient {
pub(crate) async fn get<T>(&self, path: &str) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
self.get_with_version(path, ApiVersion::V1).await
}
pub(crate) async fn get_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
let cache_key = format!("GET:{}:{:?}", path, version);
// Check cache first
if self.config.enable_offline_mode {
if let Some(cached) = self.cache.get::<T>(&cache_key).await {
return Ok(cached);
}
}
let url = self.build_url_with_version(path, version);
let request = self.client.get(&url);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await?;
return Err(crate::error::ChurchApiError::Api(format!("HTTP {}: {}", status, error_text)));
}
let response_text = response.text().await?;
let data: T = serde_json::from_str(&response_text).map_err(|e| {
crate::error::ChurchApiError::Json(e)
})?;
// Cache the result
if self.config.enable_offline_mode {
self.cache.set(&cache_key, &data, self.config.cache_ttl).await;
}
Ok(data)
}
pub(crate) async fn get_api<T>(&self, path: &str) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
self.get_api_with_version(path, ApiVersion::V1).await
}
pub(crate) async fn get_api_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
let response: ApiResponse<T> = self.get_with_version(path, version).await?;
if response.success {
response.data.ok_or_else(|| {
ChurchApiError::Api("API returned success but no data".to_string())
})
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn get_api_list<T>(&self, path: &str) -> Result<ApiListResponse<T>>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
self.get_api_list_with_version(path, ApiVersion::V1).await
}
pub(crate) async fn get_api_list_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<ApiListResponse<T>>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
let response: ApiListResponse<T> = self.get_with_version(path, version).await?;
if response.success {
Ok(response)
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn post<T, R>(&self, path: &str, data: &T) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
self.post_with_version(path, data, ApiVersion::V1).await
}
pub(crate) async fn post_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
let url = self.build_url_with_version(path, version);
let request = self.client.post(&url).json(data);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let result: R = response.json().await?;
// Invalidate related cache entries
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
Ok(result)
}
pub(crate) async fn post_api<T, R>(&self, path: &str, data: &T) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
self.post_api_with_version(path, data, ApiVersion::V1).await
}
pub(crate) async fn post_api_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
let response: ApiResponse<R> = self.post_with_version(path, data, version).await?;
if response.success {
response.data.ok_or_else(|| {
ChurchApiError::Api("API returned success but no data".to_string())
})
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn put<T, R>(&self, path: &str, data: &T) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
let url = self.build_url(path);
let request = self.client.put(&url).json(data);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let result: R = response.json().await?;
// Invalidate related cache entries
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
Ok(result)
}
pub(crate) async fn put_api<T>(&self, path: &str, data: &T) -> Result<()>
where
T: Serialize,
{
let response: ApiResponse<()> = self.put(path, data).await?;
if response.success {
Ok(())
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn delete(&self, path: &str) -> Result<()> {
let url = self.build_url(path);
let request = self.client.delete(&url);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
if !response.status().is_success() {
return Err(ChurchApiError::Http(
reqwest::Error::from(response.error_for_status().unwrap_err())
));
}
// Invalidate related cache entries
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
Ok(())
}
pub(crate) async fn delete_api(&self, path: &str) -> Result<()> {
let response: ApiResponse<()> = {
let url = self.build_url(path);
let request = self.client.delete(&url);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
response.json().await?
};
if response.success {
Ok(())
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
async fn send_with_retry(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
let mut attempts = 0;
let max_attempts = self.config.retry_attempts;
loop {
attempts += 1;
// Clone the request for potential retry
let cloned_request = request.try_clone()
.ok_or_else(|| ChurchApiError::Internal("Failed to clone request".to_string()))?;
match cloned_request.send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
return Ok(response);
} else if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(ChurchApiError::Auth("Unauthorized".to_string()));
} else if status == reqwest::StatusCode::FORBIDDEN {
return Err(ChurchApiError::PermissionDenied);
} else if status == reqwest::StatusCode::NOT_FOUND {
return Err(ChurchApiError::NotFound);
} else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempts < max_attempts {
// Exponential backoff for rate limiting
let delay = std::time::Duration::from_millis(100 * 2_u64.pow(attempts - 1));
tokio::time::sleep(delay).await;
continue;
} else {
return Err(ChurchApiError::RateLimit);
}
} else if status.is_server_error() && attempts < max_attempts {
// Retry on server errors
let delay = std::time::Duration::from_millis(500 * attempts as u64);
tokio::time::sleep(delay).await;
continue;
} else {
return Err(ChurchApiError::Http(
reqwest::Error::from(response.error_for_status().unwrap_err())
));
}
}
Err(e) => {
if attempts < max_attempts && (e.is_timeout() || e.is_connect()) {
// Retry on timeout and connection errors
let delay = std::time::Duration::from_millis(500 * attempts as u64);
tokio::time::sleep(delay).await;
continue;
} else {
return Err(ChurchApiError::Http(e));
}
}
}
}
}
async fn invalidate_cache_prefix(&self, prefix: &str) {
self.cache.invalidate_prefix(prefix).await;
}
pub(crate) fn build_query_string(&self, params: &[(&str, &str)]) -> String {
if params.is_empty() {
return String::new();
}
let query: Vec<String> = params
.iter()
.map(|(key, value)| format!("{}={}", urlencoding::encode(key), urlencoding::encode(value)))
.collect();
format!("?{}", query.join("&"))
}
pub(crate) async fn upload_file(&self, path: &str, file_data: Vec<u8>, filename: String, field_name: String) -> Result<String> {
let url = self.build_url(path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("application/octet-stream")
.map_err(|e| ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part(field_name, part);
let request = self.client.post(&url).multipart(form);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let result: ApiResponse<String> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
ChurchApiError::Api("File upload succeeded but no URL returned".to_string())
})
} else {
Err(ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}
/// Fetch an image with HTTP caching support
pub async fn get_cached_image(&self, url: &str) -> Result<CachedHttpResponse> {
// Check cache first
if let Some(cached) = self.cache.get_http_response(url).await {
println!("📸 Cache HIT for image: {}", url);
return Ok(cached);
}
println!("📸 Cache MISS for image: {}", url);
// Make HTTP request
let request = self.client.get(url);
let response = self.send_with_retry(request).await?;
let status = response.status();
let headers = response.headers().clone();
if !status.is_success() {
return Err(ChurchApiError::Http(
reqwest::Error::from(response.error_for_status().unwrap_err())
));
}
// Extract headers we care about
let mut header_map = HashMap::new();
for (name, value) in headers.iter() {
if let Ok(value_str) = value.to_str() {
header_map.insert(name.to_string(), value_str.to_string());
}
}
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
// Get response body
let data = response.bytes().await?.to_vec();
// Determine cache TTL based on content type
let ttl = if content_type.starts_with("image/") {
Duration::from_secs(24 * 60 * 60) // 24 hours for images
} else {
Duration::from_secs(5 * 60) // 5 minutes for other content
};
// Create cached response
let cached_response = CachedHttpResponse::new(
data,
content_type,
header_map,
status.as_u16(),
ttl,
);
// Store in cache
self.cache.set_http_response(url, cached_response.clone()).await;
Ok(cached_response)
}
}

35
src/client/livestream.rs Normal file
View file

@ -0,0 +1,35 @@
use crate::{
client::ChurchApiClient,
error::Result,
};
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamStatus {
pub is_live: bool,
pub last_connect_time: Option<DateTime<Utc>>,
pub last_disconnect_time: Option<DateTime<Utc>>,
pub stream_title: Option<String>,
pub stream_url: Option<String>,
pub viewer_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveStream {
pub last_connect_time: Option<DateTime<Utc>>,
pub last_disconnect_time: Option<DateTime<Utc>>,
pub viewer_count: Option<u32>,
pub stream_title: Option<String>,
pub is_live: bool,
}
/// Get current stream status from Owncast
pub async fn get_stream_status(client: &ChurchApiClient) -> Result<StreamStatus> {
client.get("/stream/status").await
}
/// Get live stream info from Owncast
pub async fn get_live_stream(client: &ChurchApiClient) -> Result<LiveStream> {
client.get("/stream/live").await
}

412
src/client/mod.rs Normal file
View file

@ -0,0 +1,412 @@
pub mod http;
pub mod events;
pub mod bulletins;
pub mod config;
pub mod contact;
pub mod sermons;
pub mod bible;
pub mod admin;
pub mod uploads;
pub mod livestream;
use crate::{
cache::MemoryCache,
config::ChurchCoreConfig,
error::Result,
models::*,
};
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct ChurchApiClient {
pub(crate) client: reqwest::Client,
pub(crate) config: ChurchCoreConfig,
pub(crate) auth_token: Arc<RwLock<Option<AuthToken>>>,
pub(crate) cache: Arc<MemoryCache>,
}
impl ChurchApiClient {
pub fn new(config: ChurchCoreConfig) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(config.timeout)
.connect_timeout(config.connect_timeout)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.user_agent(&config.user_agent)
.build()?;
let cache = Arc::new(MemoryCache::new(config.max_cache_size));
Ok(Self {
client,
config,
auth_token: Arc::new(RwLock::new(None)),
cache,
})
}
pub fn with_cache(mut self, cache: Arc<MemoryCache>) -> Self {
self.cache = cache;
self
}
pub async fn set_auth_token(&self, token: AuthToken) {
let mut auth = self.auth_token.write().await;
*auth = Some(token);
}
pub async fn clear_auth_token(&self) {
let mut auth = self.auth_token.write().await;
*auth = None;
}
pub async fn get_auth_token(&self) -> Option<AuthToken> {
let auth = self.auth_token.read().await;
auth.clone()
}
pub async fn is_authenticated(&self) -> bool {
if let Some(token) = self.get_auth_token().await {
token.is_valid()
} else {
false
}
}
pub(crate) fn build_url(&self, path: &str) -> String {
self.build_url_with_version(path, crate::models::ApiVersion::V1)
}
pub(crate) fn build_url_with_version(&self, path: &str, version: crate::models::ApiVersion) -> String {
if path.starts_with("http") {
path.to_string()
} else {
let base = self.config.api_base_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let version_prefix = version.path_prefix();
if base.ends_with("/api") {
format!("{}/{}{}", base, version_prefix, path)
} else {
format!("{}/api/{}{}", base, version_prefix, path)
}
}
}
pub(crate) async fn add_auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = self.get_auth_token().await {
if token.is_valid() {
return builder.header("Authorization", format!("{} {}", token.token_type, token.token));
}
}
builder
}
// Event operations
pub async fn get_upcoming_events(&self, limit: Option<u32>) -> Result<Vec<Event>> {
events::get_upcoming_events(self, limit).await
}
pub async fn get_events(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
events::get_events(self, params).await
}
pub async fn get_event(&self, id: &str) -> Result<Option<Event>> {
events::get_event(self, id).await
}
pub async fn create_event(&self, event: NewEvent) -> Result<String> {
events::create_event(self, event).await
}
pub async fn update_event(&self, id: &str, update: EventUpdate) -> Result<()> {
events::update_event(self, id, update).await
}
pub async fn delete_event(&self, id: &str) -> Result<()> {
events::delete_event(self, id).await
}
// Bulletin operations
pub async fn get_bulletins(&self, active_only: bool) -> Result<Vec<Bulletin>> {
bulletins::get_bulletins(self, active_only).await
}
pub async fn get_current_bulletin(&self) -> Result<Option<Bulletin>> {
bulletins::get_current_bulletin(self).await
}
pub async fn get_next_bulletin(&self) -> Result<Option<Bulletin>> {
bulletins::get_next_bulletin(self).await
}
pub async fn get_bulletin(&self, id: &str) -> Result<Option<Bulletin>> {
bulletins::get_bulletin(self, id).await
}
pub async fn create_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
bulletins::create_bulletin(self, bulletin).await
}
// V2 API methods
pub async fn get_bulletins_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
bulletins::get_bulletins_v2(self, params).await
}
pub async fn get_current_bulletin_v2(&self) -> Result<Option<Bulletin>> {
bulletins::get_current_bulletin_v2(self).await
}
pub async fn get_next_bulletin_v2(&self) -> Result<Option<Bulletin>> {
bulletins::get_next_bulletin_v2(self).await
}
// Configuration
pub async fn get_config(&self) -> Result<ChurchConfig> {
config::get_config(self).await
}
pub async fn get_config_by_id(&self, record_id: &str) -> Result<ChurchConfig> {
config::get_config_by_id(self, record_id).await
}
pub async fn update_config(&self, config: ChurchConfig) -> Result<()> {
config::update_config(self, config).await
}
// Contact operations
pub async fn submit_contact_form(&self, form: ContactForm) -> Result<String> {
contact::submit_contact_form(self, form).await
}
pub async fn get_contact_submissions(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<ContactSubmission>> {
contact::get_contact_submissions(self, params).await
}
pub async fn get_contact_submission(&self, id: &str) -> Result<Option<ContactSubmission>> {
contact::get_contact_submission(self, id).await
}
pub async fn update_contact_submission(&self, id: &str, status: ContactStatus, response: Option<String>) -> Result<()> {
contact::update_contact_submission(self, id, status, response).await
}
// Sermon operations
pub async fn get_sermons(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
sermons::get_sermons(self, params).await
}
pub async fn search_sermons(&self, search: SermonSearch, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
sermons::search_sermons(self, search, params).await
}
pub async fn get_sermon(&self, id: &str) -> Result<Option<Sermon>> {
sermons::get_sermon(self, id).await
}
pub async fn get_featured_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
sermons::get_featured_sermons(self, limit).await
}
pub async fn get_recent_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
sermons::get_recent_sermons(self, limit).await
}
pub async fn create_sermon(&self, sermon: NewSermon) -> Result<String> {
sermons::create_sermon(self, sermon).await
}
// Bible verse operations
pub async fn get_random_verse(&self) -> Result<BibleVerse> {
bible::get_random_verse(self).await
}
pub async fn get_verse_of_the_day(&self) -> Result<VerseOfTheDay> {
bible::get_verse_of_the_day(self).await
}
pub async fn get_verse_by_reference(&self, reference: &str) -> Result<Option<BibleVerse>> {
bible::get_verse_by_reference(self, reference).await
}
pub async fn get_verses_by_category(&self, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
bible::get_verses_by_category(self, category, limit).await
}
pub async fn search_verses(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
bible::search_verses(self, query, limit).await
}
// V2 API methods
// Events V2
pub async fn get_events_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
events::get_events_v2(self, params).await
}
pub async fn get_upcoming_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
events::get_upcoming_events_v2(self, limit).await
}
pub async fn get_featured_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
events::get_featured_events_v2(self, limit).await
}
pub async fn get_event_v2(&self, id: &str) -> Result<Option<Event>> {
events::get_event_v2(self, id).await
}
pub async fn submit_event(&self, submission: EventSubmission) -> Result<String> {
events::submit_event(self, submission).await
}
// Bible V2
pub async fn get_random_verse_v2(&self) -> Result<BibleVerse> {
bible::get_random_verse_v2(self).await
}
pub async fn get_bible_verses_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
bible::get_bible_verses_v2(self, params).await
}
pub async fn search_verses_v2(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
bible::search_verses_v2(self, query, limit).await
}
// Contact V2
pub async fn submit_contact_form_v2(&self, form: ContactForm) -> Result<String> {
contact::submit_contact_form_v2(self, form).await
}
// Config and Schedule V2
pub async fn get_config_v2(&self) -> Result<ChurchConfig> {
config::get_config_v2(self).await
}
pub async fn get_schedule(&self, date: Option<&str>) -> Result<Schedule> {
config::get_schedule(self, date).await
}
pub async fn get_schedule_v2(&self, date: Option<&str>) -> Result<Schedule> {
config::get_schedule_v2(self, date).await
}
pub async fn get_conference_data(&self) -> Result<ConferenceData> {
config::get_conference_data(self).await
}
pub async fn get_conference_data_v2(&self) -> Result<ConferenceData> {
config::get_conference_data_v2(self).await
}
pub async fn get_livestreams(&self) -> Result<Vec<Sermon>> {
sermons::get_livestreams(self).await
}
// Owncast Live Streaming
pub async fn get_stream_status(&self) -> Result<livestream::StreamStatus> {
livestream::get_stream_status(self).await
}
pub async fn get_live_stream(&self) -> Result<livestream::LiveStream> {
livestream::get_live_stream(self).await
}
// Admin operations
// Admin Bulletins
pub async fn create_admin_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
admin::create_bulletin(self, bulletin).await
}
pub async fn update_admin_bulletin(&self, id: &str, update: BulletinUpdate) -> Result<()> {
admin::update_bulletin(self, id, update).await
}
pub async fn delete_admin_bulletin(&self, id: &str) -> Result<()> {
admin::delete_bulletin(self, id).await
}
// Admin Events
pub async fn create_admin_event(&self, event: NewEvent) -> Result<String> {
admin::create_admin_event(self, event).await
}
pub async fn update_admin_event(&self, id: &str, update: EventUpdate) -> Result<()> {
admin::update_admin_event(self, id, update).await
}
pub async fn delete_admin_event(&self, id: &str) -> Result<()> {
admin::delete_admin_event(self, id).await
}
// Admin Pending Events
pub async fn get_pending_events(&self) -> Result<Vec<PendingEvent>> {
admin::get_pending_events(self).await
}
pub async fn approve_pending_event(&self, id: &str) -> Result<()> {
admin::approve_pending_event(self, id).await
}
pub async fn reject_pending_event(&self, id: &str) -> Result<()> {
admin::reject_pending_event(self, id).await
}
pub async fn delete_pending_event(&self, id: &str) -> Result<()> {
admin::delete_pending_event(self, id).await
}
// Admin Users
pub async fn get_admin_users(&self) -> Result<Vec<User>> {
admin::get_users(self).await
}
// Admin Schedule
pub async fn create_admin_schedule(&self, schedule: NewSchedule) -> Result<String> {
admin::create_schedule(self, schedule).await
}
pub async fn update_admin_schedule(&self, date: &str, update: ScheduleUpdate) -> Result<()> {
admin::update_schedule(self, date, update).await
}
pub async fn delete_admin_schedule(&self, date: &str) -> Result<()> {
admin::delete_schedule(self, date).await
}
pub async fn get_all_admin_schedules(&self) -> Result<Vec<Schedule>> {
admin::get_all_schedules(self).await
}
pub async fn get_admin_config(&self) -> Result<ChurchConfig> {
admin::get_admin_config(self).await
}
// File Upload operations
pub async fn upload_bulletin_pdf(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
uploads::upload_bulletin_pdf(self, bulletin_id, file_data, filename).await
}
pub async fn upload_bulletin_cover(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
uploads::upload_bulletin_cover(self, bulletin_id, file_data, filename).await
}
pub async fn upload_event_image(&self, event_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
uploads::upload_event_image(self, event_id, file_data, filename).await
}
// Utility methods
pub async fn health_check(&self) -> Result<bool> {
let url = self.build_url("/health");
let response = self.client.get(&url).send().await?;
Ok(response.status().is_success())
}
pub async fn clear_cache(&self) {
self.cache.clear().await;
}
pub async fn get_cache_stats(&self) -> (usize, usize) {
(self.cache.len().await, self.config.max_cache_size)
}
}

237
src/client/sermons.rs Normal file
View file

@ -0,0 +1,237 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{Sermon, ApiSermon, NewSermon, SermonSearch, PaginationParams, ApiListResponse, DeviceCapabilities},
};
pub async fn get_sermons(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
let mut path = "/sermons".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list(&path).await
}
pub async fn search_sermons(
client: &ChurchApiClient,
search: SermonSearch,
params: Option<PaginationParams>
) -> Result<ApiListResponse<Sermon>> {
let mut path = "/sermons/search".to_string();
let mut query_params = Vec::new();
if let Some(query) = &search.query {
query_params.push(("q", query.clone()));
}
if let Some(speaker) = &search.speaker {
query_params.push(("speaker", speaker.clone()));
}
if let Some(category) = &search.category {
query_params.push(("category", format!("{:?}", category).to_lowercase()));
}
if let Some(series) = &search.series {
query_params.push(("series", series.clone()));
}
if let Some(featured_only) = search.featured_only {
if featured_only {
query_params.push(("featured", "true".to_string()));
}
}
if let Some(has_video) = search.has_video {
if has_video {
query_params.push(("has_video", "true".to_string()));
}
}
if let Some(has_audio) = search.has_audio {
if has_audio {
query_params.push(("has_audio", "true".to_string()));
}
}
if let Some(params) = params {
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
client.get_api_list(&path).await
}
pub async fn get_sermon(client: &ChurchApiClient, id: &str) -> Result<Option<Sermon>> {
let path = format!("/sermons/{}", id);
match client.get_api(&path).await {
Ok(sermon) => Ok(Some(sermon)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_featured_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
let mut path = "/sermons/featured".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
// Helper function to convert seconds to human readable duration
fn format_duration_seconds(seconds: u32) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let remaining_seconds = seconds % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, remaining_seconds)
} else {
format!("{}:{:02}", minutes, remaining_seconds)
}
}
// Shared function to convert API sermon/livestream data to Sermon model
fn convert_api_sermon_to_sermon(api_sermon: ApiSermon, category: crate::models::sermon::SermonCategory) -> Sermon {
// Parse date string to DateTime if available
let date = if let Some(date_str) = &api_sermon.date {
chrono::DateTime::parse_from_str(&format!("{} 00:00:00 +0000", date_str), "%Y-%m-%d %H:%M:%S %z")
.unwrap_or_else(|_| chrono::Utc::now().into())
.with_timezone(&chrono::Utc)
} else {
chrono::Utc::now()
};
// Duration is already in string format from the API, so use it directly
let duration_string = Some(api_sermon.duration.clone());
// Generate optimal streaming URL for the device
let media_url = if !api_sermon.id.is_empty() {
let base_url = "https://api.rockvilletollandsda.church"; // TODO: Get from config
let streaming_url = DeviceCapabilities::get_optimal_streaming_url(base_url, &api_sermon.id);
Some(streaming_url.url)
} else {
api_sermon.video_url.clone()
};
Sermon {
id: api_sermon.id.clone(),
title: api_sermon.title,
speaker: api_sermon.speaker.unwrap_or("Unknown".to_string()),
description: api_sermon.description.unwrap_or_default(),
date,
scripture_reference: api_sermon.scripture_reading.unwrap_or_default(),
series: None,
duration_string,
media_url,
audio_url: api_sermon.audio_url,
video_url: api_sermon.video_url,
transcript: None,
thumbnail: api_sermon.thumbnail,
tags: None,
category,
is_featured: false,
view_count: 0,
download_count: 0,
created_at: date,
updated_at: date,
}
}
pub async fn get_recent_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
let mut path = "/sermons".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
// The new API returns a wrapper with "sermons" array
#[derive(serde::Deserialize, serde::Serialize)]
struct SermonsResponse {
success: bool,
data: Vec<ApiSermon>,
message: Option<String>,
}
let response: SermonsResponse = client.get(&path).await?;
// Convert using shared logic
let sermons = response.data.into_iter()
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::Regular))
.collect();
Ok(sermons)
}
pub async fn create_sermon(client: &ChurchApiClient, sermon: NewSermon) -> Result<String> {
client.post_api("/sermons", &sermon).await
}
// Livestreams endpoint - reuses ApiSermon since format is identical
pub async fn get_livestreams(client: &ChurchApiClient) -> Result<Vec<Sermon>> {
// Use the new API endpoint for livestreams
let path = "/livestreams";
// The new API returns a wrapper with "data" array (same format as sermons endpoint)
#[derive(serde::Deserialize, serde::Serialize)]
struct LivestreamsResponse {
success: bool,
data: Vec<ApiSermon>,
message: Option<String>,
}
let response: LivestreamsResponse = client.get(path).await?;
// Convert using shared logic - same as regular sermons but different category
let sermons = response.data.into_iter()
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::LivestreamArchive))
.collect();
Ok(sermons)
}

119
src/client/uploads.rs Normal file
View file

@ -0,0 +1,119 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::UploadResponse,
};
/// Upload PDF file for a bulletin
pub async fn upload_bulletin_pdf(
client: &ChurchApiClient,
bulletin_id: &str,
file_data: Vec<u8>,
filename: String,
) -> Result<UploadResponse> {
let path = format!("/upload/bulletins/{}/pdf", bulletin_id);
let url = client.build_url(&path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("application/pdf")
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part("file", part);
let request = client.client.post(&url).multipart(form);
let request = client.add_auth_header(request).await;
let response = request.send().await?;
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
})
} else {
Err(crate::error::ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}
/// Upload cover image for a bulletin
pub async fn upload_bulletin_cover(
client: &ChurchApiClient,
bulletin_id: &str,
file_data: Vec<u8>,
filename: String,
) -> Result<UploadResponse> {
let path = format!("/upload/bulletins/{}/cover", bulletin_id);
let url = client.build_url(&path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("image/jpeg")
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part("file", part);
let request = client.client.post(&url).multipart(form);
let request = client.add_auth_header(request).await;
let response = request.send().await?;
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
})
} else {
Err(crate::error::ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}
/// Upload image for an event
pub async fn upload_event_image(
client: &ChurchApiClient,
event_id: &str,
file_data: Vec<u8>,
filename: String,
) -> Result<UploadResponse> {
let path = format!("/upload/events/{}/image", event_id);
let url = client.build_url(&path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("image/jpeg")
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part("file", part);
let request = client.client.post(&url).multipart(form);
let request = client.add_auth_header(request).await;
let response = request.send().await?;
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
})
} else {
Err(crate::error::ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}

69
src/config.rs Normal file
View file

@ -0,0 +1,69 @@
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ChurchCoreConfig {
pub api_base_url: String,
pub cache_ttl: Duration,
pub timeout: Duration,
pub connect_timeout: Duration,
pub retry_attempts: u32,
pub enable_offline_mode: bool,
pub max_cache_size: usize,
pub user_agent: String,
}
impl Default for ChurchCoreConfig {
fn default() -> Self {
Self {
api_base_url: "https://api.rockvilletollandsda.church".to_string(),
cache_ttl: Duration::from_secs(300), // 5 minutes
timeout: Duration::from_secs(10),
connect_timeout: Duration::from_secs(5),
retry_attempts: 3,
enable_offline_mode: true,
max_cache_size: 1000,
user_agent: format!("church-core/{}", env!("CARGO_PKG_VERSION")),
}
}
}
impl ChurchCoreConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.api_base_url = url.into();
self
}
pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
self.cache_ttl = ttl;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_retry_attempts(mut self, attempts: u32) -> Self {
self.retry_attempts = attempts;
self
}
pub fn with_offline_mode(mut self, enabled: bool) -> Self {
self.enable_offline_mode = enabled;
self
}
pub fn with_max_cache_size(mut self, size: usize) -> Self {
self.max_cache_size = size;
self
}
pub fn with_user_agent(mut self, agent: impl Into<String>) -> Self {
self.user_agent = agent.into();
self
}
}

62
src/error.rs Normal file
View file

@ -0,0 +1,62 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ChurchApiError>;
#[derive(Debug, Error)]
pub enum ChurchApiError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON parsing failed: {0}")]
Json(#[from] serde_json::Error),
#[error("Date parsing failed: {0}")]
DateParse(String),
#[error("API returned error: {0}")]
Api(String),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Network error: {0}")]
Network(String),
#[error("Timeout error: operation took too long")]
Timeout,
#[error("Rate limit exceeded")]
RateLimit,
#[error("Resource not found")]
NotFound,
#[error("Permission denied")]
PermissionDenied,
#[error("Internal error: {0}")]
Internal(String),
}
impl ChurchApiError {
pub fn is_network_error(&self) -> bool {
matches!(self, Self::Http(_) | Self::Network(_) | Self::Timeout)
}
pub fn is_auth_error(&self) -> bool {
matches!(self, Self::Auth(_) | Self::PermissionDenied)
}
pub fn is_temporary(&self) -> bool {
matches!(self, Self::Timeout | Self::RateLimit | Self::Network(_))
}
}

12
src/ffi.rs Normal file
View file

@ -0,0 +1,12 @@
// FFI module for church-core
// This module is only compiled when the ffi feature is enabled
use crate::{ChurchApiClient, ChurchCoreConfig, ChurchApiError};
// Re-export for UniFFI
pub use crate::{
models::*,
ChurchApiClient,
ChurchCoreConfig,
ChurchApiError,
};

24
src/lib.rs Normal file
View file

@ -0,0 +1,24 @@
pub mod client;
pub mod models;
pub mod auth;
pub mod cache;
pub mod utils;
pub mod error;
pub mod config;
pub use client::ChurchApiClient;
pub use config::ChurchCoreConfig;
pub use error::{ChurchApiError, Result};
pub use models::*;
pub use cache::*;
#[cfg(feature = "wasm")]
pub mod wasm;
#[cfg(feature = "uniffi")]
pub mod uniffi_wrapper;
#[cfg(feature = "uniffi")]
pub use uniffi_wrapper::*;
#[cfg(feature = "uniffi")]
uniffi::include_scaffolding!("church_core");

92
src/models/admin.rs Normal file
View file

@ -0,0 +1,92 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// User information for admin user management
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
pub id: String,
pub username: String,
pub email: Option<String>,
pub role: AdminUserRole,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AdminUserRole {
#[serde(rename = "admin")]
Admin,
#[serde(rename = "moderator")]
Moderator,
#[serde(rename = "user")]
User,
}
/// Schedule data
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Schedule {
pub date: String, // YYYY-MM-DD format
pub sabbath_school: Option<String>,
pub divine_worship: Option<String>,
pub scripture_reading: Option<String>,
pub sunset: Option<String>,
pub special_notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Conference data
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConferenceData {
pub id: String,
pub name: String,
pub website: Option<String>,
pub contact_info: Option<String>,
pub leadership: Option<Vec<ConferenceLeader>>,
pub announcements: Option<Vec<String>>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConferenceLeader {
pub name: String,
pub title: String,
pub email: Option<String>,
pub phone: Option<String>,
}
/// New schedule creation
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewSchedule {
pub date: String, // YYYY-MM-DD format
pub sabbath_school: Option<String>,
pub divine_worship: Option<String>,
pub scripture_reading: Option<String>,
pub sunset: Option<String>,
pub special_notes: Option<String>,
}
/// Schedule update
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScheduleUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub sabbath_school: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub divine_worship: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripture_reading: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sunset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_notes: Option<String>,
}
/// File upload response
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UploadResponse {
pub file_path: String,
pub pdf_path: Option<String>, // Full URL to the uploaded file
pub message: String,
}

276
src/models/auth.rs Normal file
View file

@ -0,0 +1,276 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthToken {
pub token: String,
pub token_type: String,
pub expires_at: DateTime<Utc>,
pub user_id: Option<String>,
pub user_name: Option<String>,
pub user_email: Option<String>,
pub permissions: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LoginRequest {
pub identity: String, // email or username
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LoginResponse {
pub token: String,
pub user: AuthUser,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthUser {
pub id: String,
pub email: String,
pub name: String,
pub username: Option<String>,
pub avatar: Option<String>,
pub verified: bool,
pub role: UserRole,
pub permissions: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RefreshTokenRequest {
pub refresh_token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RefreshTokenResponse {
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PasswordResetRequest {
pub email: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PasswordResetConfirm {
pub token: String,
pub new_password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub name: String,
pub username: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EmailVerificationRequest {
pub token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum UserRole {
#[serde(rename = "admin")]
Admin,
#[serde(rename = "pastor")]
Pastor,
#[serde(rename = "elder")]
Elder,
#[serde(rename = "deacon")]
Deacon,
#[serde(rename = "ministry_leader")]
MinistryLeader,
#[serde(rename = "member")]
Member,
#[serde(rename = "visitor")]
Visitor,
#[serde(rename = "guest")]
Guest,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PocketBaseAuthResponse {
pub token: String,
pub record: PocketBaseUser,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PocketBaseUser {
pub id: String,
pub email: String,
pub name: String,
pub username: Option<String>,
pub avatar: Option<String>,
pub verified: bool,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
impl AuthToken {
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_valid(&self) -> bool {
!self.is_expired() && !self.token.is_empty()
}
pub fn expires_in_seconds(&self) -> i64 {
(self.expires_at - Utc::now()).num_seconds().max(0)
}
pub fn expires_in_minutes(&self) -> i64 {
(self.expires_at - Utc::now()).num_minutes().max(0)
}
pub fn has_permission(&self, permission: &str) -> bool {
self.permissions.contains(&permission.to_string())
}
pub fn has_any_permission(&self, permissions: &[&str]) -> bool {
permissions.iter().any(|p| self.has_permission(p))
}
pub fn has_all_permissions(&self, permissions: &[&str]) -> bool {
permissions.iter().all(|p| self.has_permission(p))
}
}
impl AuthUser {
pub fn is_admin(&self) -> bool {
matches!(self.role, UserRole::Admin)
}
pub fn is_pastor(&self) -> bool {
matches!(self.role, UserRole::Pastor)
}
pub fn is_leadership(&self) -> bool {
matches!(
self.role,
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
)
}
pub fn is_member(&self) -> bool {
matches!(
self.role,
UserRole::Admin
| UserRole::Pastor
| UserRole::Elder
| UserRole::Deacon
| UserRole::MinistryLeader
| UserRole::Member
)
}
pub fn can_edit_content(&self) -> bool {
matches!(
self.role,
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::MinistryLeader
)
}
pub fn can_moderate(&self) -> bool {
matches!(
self.role,
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
)
}
pub fn display_name(&self) -> String {
if !self.name.is_empty() {
self.name.clone()
} else if let Some(username) = &self.username {
username.clone()
} else {
self.email.clone()
}
}
}
impl UserRole {
pub fn display_name(&self) -> &'static str {
match self {
UserRole::Admin => "Administrator",
UserRole::Pastor => "Pastor",
UserRole::Elder => "Elder",
UserRole::Deacon => "Deacon",
UserRole::MinistryLeader => "Ministry Leader",
UserRole::Member => "Member",
UserRole::Visitor => "Visitor",
UserRole::Guest => "Guest",
}
}
pub fn permissions(&self) -> Vec<&'static str> {
match self {
UserRole::Admin => vec![
"admin.*",
"events.*",
"bulletins.*",
"sermons.*",
"contacts.*",
"users.*",
"config.*",
],
UserRole::Pastor => vec![
"events.*",
"bulletins.*",
"sermons.*",
"contacts.read",
"contacts.respond",
"users.read",
],
UserRole::Elder => vec![
"events.read",
"events.create",
"bulletins.read",
"sermons.read",
"contacts.read",
"contacts.respond",
],
UserRole::Deacon => vec![
"events.read",
"bulletins.read",
"sermons.read",
"contacts.read",
],
UserRole::MinistryLeader => vec![
"events.read",
"events.create",
"bulletins.read",
"sermons.read",
],
UserRole::Member => vec![
"events.read",
"bulletins.read",
"sermons.read",
],
UserRole::Visitor => vec![
"events.read",
"bulletins.read",
"sermons.read",
],
UserRole::Guest => vec![
"events.read",
"bulletins.read",
],
}
}
}

136
src/models/bible.rs Normal file
View file

@ -0,0 +1,136 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BibleVerse {
pub text: String,
pub reference: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub book: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chapter: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verse: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<VerseCategory>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VerseOfTheDay {
pub verse: BibleVerse,
pub date: chrono::NaiveDate,
#[serde(skip_serializing_if = "Option::is_none")]
pub commentary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum VerseCategory {
#[serde(rename = "comfort")]
Comfort,
#[serde(rename = "hope")]
Hope,
#[serde(rename = "faith")]
Faith,
#[serde(rename = "love")]
Love,
#[serde(rename = "peace")]
Peace,
#[serde(rename = "strength")]
Strength,
#[serde(rename = "wisdom")]
Wisdom,
#[serde(rename = "guidance")]
Guidance,
#[serde(rename = "forgiveness")]
Forgiveness,
#[serde(rename = "salvation")]
Salvation,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "praise")]
Praise,
#[serde(rename = "thanksgiving")]
Thanksgiving,
#[serde(rename = "other")]
Other,
}
impl BibleVerse {
pub fn new(text: String, reference: String) -> Self {
Self {
text,
reference,
version: None,
book: None,
chapter: None,
verse: None,
category: None,
}
}
pub fn with_version(mut self, version: String) -> Self {
self.version = Some(version);
self
}
pub fn with_book(mut self, book: String) -> Self {
self.book = Some(book);
self
}
pub fn with_location(mut self, chapter: u32, verse: u32) -> Self {
self.chapter = Some(chapter);
self.verse = Some(verse);
self
}
pub fn with_category(mut self, category: VerseCategory) -> Self {
self.category = Some(category);
self
}
}
impl VerseOfTheDay {
pub fn new(verse: BibleVerse, date: chrono::NaiveDate) -> Self {
Self {
verse,
date,
commentary: None,
theme: None,
}
}
pub fn with_commentary(mut self, commentary: String) -> Self {
self.commentary = Some(commentary);
self
}
pub fn with_theme(mut self, theme: String) -> Self {
self.theme = Some(theme);
self
}
}
impl VerseCategory {
pub fn display_name(&self) -> &'static str {
match self {
VerseCategory::Comfort => "Comfort",
VerseCategory::Hope => "Hope",
VerseCategory::Faith => "Faith",
VerseCategory::Love => "Love",
VerseCategory::Peace => "Peace",
VerseCategory::Strength => "Strength",
VerseCategory::Wisdom => "Wisdom",
VerseCategory::Guidance => "Guidance",
VerseCategory::Forgiveness => "Forgiveness",
VerseCategory::Salvation => "Salvation",
VerseCategory::Prayer => "Prayer",
VerseCategory::Praise => "Praise",
VerseCategory::Thanksgiving => "Thanksgiving",
VerseCategory::Other => "Other",
}
}
}

240
src/models/bulletin.rs Normal file
View file

@ -0,0 +1,240 @@
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Bulletin {
pub id: String,
pub title: String,
pub date: NaiveDate,
pub sabbath_school: String,
pub divine_worship: String,
pub scripture_reading: String,
pub sunset: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
#[serde(default)]
pub is_active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub announcements: Option<Vec<Announcement>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymns: Option<Vec<BulletinHymn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_music: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offering_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sermon_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liturgy: Option<Vec<LiturgyItem>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewBulletin {
pub title: String,
pub date: NaiveDate,
pub sabbath_school: String,
pub divine_worship: String,
pub scripture_reading: String,
pub sunset: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
#[serde(default)]
pub is_active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub announcements: Option<Vec<Announcement>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymns: Option<Vec<BulletinHymn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_music: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offering_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sermon_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liturgy: Option<Vec<LiturgyItem>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BulletinUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<NaiveDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sabbath_school: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub divine_worship: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripture_reading: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sunset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub announcements: Option<Vec<Announcement>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymns: Option<Vec<BulletinHymn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_music: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offering_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sermon_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liturgy: Option<Vec<LiturgyItem>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Announcement {
pub id: Option<String>,
pub title: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<AnnouncementCategory>,
#[serde(default)]
pub is_urgent: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BulletinHymn {
pub number: u32,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<HymnCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verses: Option<Vec<u32>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LiturgyItem {
pub order: u32,
pub item_type: LiturgyType,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub leader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripture_reference: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymn_number: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AnnouncementCategory {
#[serde(rename = "general")]
General,
#[serde(rename = "ministry")]
Ministry,
#[serde(rename = "social")]
Social,
#[serde(rename = "urgent")]
Urgent,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "community")]
Community,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum HymnCategory {
#[serde(rename = "opening")]
Opening,
#[serde(rename = "closing")]
Closing,
#[serde(rename = "offertory")]
Offertory,
#[serde(rename = "communion")]
Communion,
#[serde(rename = "special")]
Special,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum LiturgyType {
#[serde(rename = "prelude")]
Prelude,
#[serde(rename = "welcome")]
Welcome,
#[serde(rename = "opening_hymn")]
OpeningHymn,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "scripture")]
Scripture,
#[serde(rename = "children_story")]
ChildrenStory,
#[serde(rename = "hymn")]
Hymn,
#[serde(rename = "offertory")]
Offertory,
#[serde(rename = "sermon")]
Sermon,
#[serde(rename = "closing_hymn")]
ClosingHymn,
#[serde(rename = "benediction")]
Benediction,
#[serde(rename = "postlude")]
Postlude,
#[serde(rename = "announcements")]
Announcements,
#[serde(rename = "special_music")]
SpecialMusic,
}
impl Bulletin {
pub fn has_pdf(&self) -> bool {
self.pdf_path.is_some()
}
pub fn has_cover_image(&self) -> bool {
self.cover_image.is_some()
}
pub fn active_announcements(&self) -> Vec<&Announcement> {
self.announcements
.as_ref()
.map(|announcements| {
announcements
.iter()
.filter(|announcement| {
announcement.expires_at
.map_or(true, |expires| expires > Utc::now())
})
.collect()
})
.unwrap_or_default()
}
pub fn urgent_announcements(&self) -> Vec<&Announcement> {
self.announcements
.as_ref()
.map(|announcements| {
announcements
.iter()
.filter(|announcement| announcement.is_urgent)
.collect()
})
.unwrap_or_default()
}
}

343
src/models/client_models.rs Normal file
View file

@ -0,0 +1,343 @@
use serde::{Deserialize, Serialize};
use crate::models::event::{Event, RecurringType};
use crate::models::bulletin::Bulletin;
use crate::models::sermon::Sermon;
use chrono::{DateTime, Utc, Local, Timelike};
/// Client-facing Event model with both raw timestamps and formatted display strings
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClientEvent {
pub id: String,
pub title: String,
pub description: String,
// Raw ISO timestamps for calendar/system APIs
#[serde(rename = "start_time")]
pub start_time: String, // ISO timestamp like "2025-08-13T05:00:00-04:00"
#[serde(rename = "end_time")]
pub end_time: String, // ISO timestamp like "2025-08-13T06:00:00-04:00"
// Formatted display strings for UI
#[serde(rename = "formatted_time")]
pub formatted_time: String, // "6:00 PM - 8:00 PM"
#[serde(rename = "formatted_date")]
pub formatted_date: String, // "Friday, August 15, 2025"
#[serde(rename = "formatted_date_time")]
pub formatted_date_time: String, // "Friday, August 15, 2025 at 6:00 PM"
// Additional display fields for UI components
#[serde(rename = "day_of_month")]
pub day_of_month: String, // "15"
#[serde(rename = "month_abbreviation")]
pub month_abbreviation: String, // "AUG"
#[serde(rename = "time_string")]
pub time_string: String, // "6:00 PM - 8:00 PM" (alias for formatted_time)
#[serde(rename = "is_multi_day")]
pub is_multi_day: bool, // true if event spans multiple days
#[serde(rename = "detailed_time_display")]
pub detailed_time_display: String, // Full time range for detail views
pub location: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "location_url")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
pub category: String,
#[serde(rename = "is_featured")]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none", rename = "recurring_type")]
pub recurring_type: Option<String>,
#[serde(rename = "created_at")]
pub created_at: String, // ISO timestamp
#[serde(rename = "updated_at")]
pub updated_at: String, // ISO timestamp
}
/// Helper function to format time range from DateTime objects in local timezone
fn format_time_range_from_datetime(start_time: &DateTime<Utc>, end_time: &DateTime<Utc>) -> String {
// Convert UTC to local timezone for display
let start_local = start_time.with_timezone(&Local);
let end_local = end_time.with_timezone(&Local);
// Use consistent formatting: always show hour without leading zero, include minutes, use PM/AM
let start_formatted = if start_local.minute() == 0 {
start_local.format("%l %p").to_string().trim().to_string()
} else {
start_local.format("%l:%M %p").to_string().trim().to_string()
};
let end_formatted = if end_local.minute() == 0 {
end_local.format("%l %p").to_string().trim().to_string()
} else {
end_local.format("%l:%M %p").to_string().trim().to_string()
};
// If start and end times are the same, just show one time
if start_formatted == end_formatted {
start_formatted
} else {
format!("{} - {}", start_formatted, end_formatted)
}
}
impl From<Event> for ClientEvent {
fn from(event: Event) -> Self {
let description = event.clean_description();
let category = event.category.to_string();
let recurring_type = event.recurring_type.as_ref().map(|rt| rt.to_string());
// Raw ISO timestamps for calendar/system APIs
let start_time = event.start_time.format("%Y-%m-%dT%H:%M:%S%z").to_string();
let end_time = event.end_time.format("%Y-%m-%dT%H:%M:%S%z").to_string();
// Generate formatted display strings in local timezone
let start_local = event.start_time.with_timezone(&Local);
let end_local = event.end_time.with_timezone(&Local);
// Check if event spans multiple days
let is_multi_day = start_local.date_naive() != end_local.date_naive();
let (formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display) = if is_multi_day {
// Multi-day event: show date range for formatted_date, but start time for simplified views
let start_date = start_local.format("%B %d, %Y").to_string();
let end_date = end_local.format("%B %d, %Y").to_string();
let formatted_date = format!("{} - {}", start_date, end_date);
// For detailed view: show full date range with full time range
let time_range = format_time_range_from_datetime(&event.start_time, &event.end_time);
let formatted_time = format!("{} - {}, {}",
start_local.format("%b %d").to_string(),
end_local.format("%b %d").to_string(),
time_range
);
// For HomeFeed simplified view: just show start time
let start_time_formatted = if start_local.minute() == 0 {
start_local.format("%l %p").to_string().trim().to_string()
} else {
start_local.format("%l:%M %p").to_string().trim().to_string()
};
let time_string = start_time_formatted;
// For detail views: use the same time_range that eliminates redundancy
let detailed_time_display = time_range.clone();
let formatted_date_time = format!("{} - {}", start_date, end_date);
// Use start date for calendar display
let day_of_month = start_local.format("%d").to_string().trim_start_matches('0').to_string();
let month_abbreviation = start_local.format("%b").to_string().to_uppercase();
(formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display)
} else {
// Single day event: show time range
let formatted_time = format_time_range_from_datetime(&event.start_time, &event.end_time);
let formatted_date = start_local.format("%B %d, %Y").to_string();
// Use consistent time formatting for single events too
let time_formatted = if start_local.minute() == 0 {
start_local.format("%l %p").to_string().trim().to_string()
} else {
start_local.format("%l:%M %p").to_string().trim().to_string()
};
let formatted_date_time = format!("{} at {}", formatted_date, time_formatted);
let day_of_month = start_local.format("%d").to_string().trim_start_matches('0').to_string();
let month_abbreviation = start_local.format("%b").to_string().to_uppercase();
// For single events, time_string should just be start time for HomeFeed
let time_string = time_formatted;
// For single events, detailed_time_display is same as formatted_time
let detailed_time_display = formatted_time.clone();
(formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display)
};
let created_at = event.created_at.format("%Y-%m-%dT%H:%M:%S%z").to_string();
let updated_at = event.updated_at.format("%Y-%m-%dT%H:%M:%S%z").to_string();
Self {
id: event.id,
title: event.title,
description,
start_time,
end_time,
formatted_time,
formatted_date,
formatted_date_time,
day_of_month,
month_abbreviation,
time_string,
is_multi_day,
detailed_time_display,
location: event.location,
location_url: event.location_url,
image: event.image,
thumbnail: event.thumbnail,
category,
is_featured: event.is_featured,
recurring_type,
created_at,
updated_at,
}
}
}
/// Client-facing Bulletin model with formatted dates
/// Serializes to camelCase JSON for iOS compatibility
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClientBulletin {
pub id: String,
pub title: String,
pub date: String, // Pre-formatted date string
#[serde(rename = "sabbathSchool")]
pub sabbath_school: String,
#[serde(rename = "divineWorship")]
pub divine_worship: String,
#[serde(rename = "scriptureReading")]
pub scripture_reading: String,
pub sunset: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "pdfPath")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "coverImage")]
pub cover_image: Option<String>,
#[serde(rename = "isActive")]
pub is_active: bool,
// Add other fields as needed
}
impl From<Bulletin> for ClientBulletin {
fn from(bulletin: Bulletin) -> Self {
Self {
id: bulletin.id,
title: bulletin.title,
date: bulletin.date.format("%A, %B %d, %Y").to_string(), // Format NaiveDate to string
sabbath_school: bulletin.sabbath_school,
divine_worship: bulletin.divine_worship,
scripture_reading: bulletin.scripture_reading,
sunset: bulletin.sunset,
pdf_path: bulletin.pdf_path,
cover_image: bulletin.cover_image,
is_active: bulletin.is_active,
}
}
}
/// Client-facing Sermon model with pre-formatted dates and cleaned data
/// Serializes to camelCase JSON for iOS compatibility
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClientSermon {
pub id: String,
pub title: String,
pub speaker: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>, // Pre-formatted date string
#[serde(skip_serializing_if = "Option::is_none", rename = "audioUrl")]
pub audio_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "videoUrl")]
pub video_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>, // Pre-formatted duration
#[serde(skip_serializing_if = "Option::is_none", rename = "mediaType")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "scriptureReading")]
pub scripture_reading: Option<String>,
}
impl ClientSermon {
/// Create a ClientSermon from a Sermon with URL conversion using base API URL
pub fn from_sermon_with_base_url(sermon: Sermon, base_url: &str) -> Self {
let date = sermon.date.format("%B %d, %Y").to_string();
let media_type = if sermon.has_video() {
Some("Video".to_string())
} else if sermon.has_audio() {
Some("Audio".to_string())
} else {
None
};
// Helper function to convert relative URLs to full URLs
let make_full_url = |url: Option<String>| -> Option<String> {
url.map(|u| {
if u.starts_with("http://") || u.starts_with("https://") {
// Already a full URL
u
} else if u.starts_with("/") {
// Relative URL starting with /
let base = base_url.trim_end_matches('/');
format!("{}{}", base, u)
} else {
// Relative URL not starting with /
let base = base_url.trim_end_matches('/');
format!("{}/{}", base, u)
}
})
};
Self {
id: sermon.id,
title: sermon.title,
speaker: sermon.speaker,
description: Some(sermon.description),
date: Some(date),
audio_url: make_full_url(sermon.audio_url),
video_url: make_full_url(sermon.video_url),
duration: sermon.duration_string, // Use raw duration string from API
media_type,
thumbnail: make_full_url(sermon.thumbnail),
image: None, // Sermons don't have separate image field
scripture_reading: if sermon.scripture_reference.is_empty() { None } else { Some(sermon.scripture_reference.clone()) },
}
}
}
impl From<Sermon> for ClientSermon {
fn from(sermon: Sermon) -> Self {
let date = sermon.date.format("%B %d, %Y").to_string();
let media_type = if sermon.has_video() {
Some("Video".to_string())
} else if sermon.has_audio() {
Some("Audio".to_string())
} else {
None
};
Self {
id: sermon.id,
title: sermon.title,
speaker: sermon.speaker,
description: Some(sermon.description),
date: Some(date),
audio_url: sermon.audio_url,
video_url: sermon.video_url,
duration: sermon.duration_string, // Use raw duration string from API
media_type,
thumbnail: sermon.thumbnail,
image: None, // Sermons don't have separate image field
scripture_reading: if sermon.scripture_reference.is_empty() { None } else { Some(sermon.scripture_reference.clone()) },
}
}
}
// Add ToString implementations for enums if not already present
impl ToString for RecurringType {
fn to_string(&self) -> String {
match self {
RecurringType::Daily => "Daily".to_string(),
RecurringType::Weekly => "Weekly".to_string(),
RecurringType::Biweekly => "Bi-weekly".to_string(),
RecurringType::Monthly => "Monthly".to_string(),
RecurringType::FirstTuesday => "First Tuesday".to_string(),
RecurringType::FirstSabbath => "First Sabbath".to_string(),
RecurringType::LastSabbath => "Last Sabbath".to_string(),
}
}
}

73
src/models/common.rs Normal file
View file

@ -0,0 +1,73 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiListResponse<T> {
pub success: bool,
pub data: ApiListData<T>,
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiListData<T> {
pub items: Vec<T>,
pub total: u32,
pub page: u32,
pub per_page: u32,
pub has_more: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PaginationParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub sort: Option<String>,
pub filter: Option<String>,
}
impl Default for PaginationParams {
fn default() -> Self {
Self {
page: Some(1),
per_page: Some(50),
sort: None,
filter: None,
}
}
}
impl PaginationParams {
pub fn new() -> Self {
Self::default()
}
pub fn with_page(mut self, page: u32) -> Self {
self.page = Some(page);
self
}
pub fn with_per_page(mut self, per_page: u32) -> Self {
self.per_page = Some(per_page);
self
}
pub fn with_sort(mut self, sort: impl Into<String>) -> Self {
self.sort = Some(sort.into());
self
}
pub fn with_filter(mut self, filter: impl Into<String>) -> Self {
self.filter = Some(filter.into());
self
}
}

253
src/models/config.rs Normal file
View file

@ -0,0 +1,253 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Coordinates {
pub lat: f64,
pub lng: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChurchConfig {
pub church_name: Option<String>,
pub church_address: Option<String>,
pub po_box: Option<String>,
pub contact_phone: Option<String>,
pub contact_email: Option<String>,
pub website_url: Option<String>,
pub google_maps_url: Option<String>,
pub facebook_url: Option<String>,
pub youtube_url: Option<String>,
pub instagram_url: Option<String>,
pub about_text: Option<String>,
pub mission_statement: Option<String>,
pub tagline: Option<String>,
pub brand_color: Option<String>,
pub donation_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_times: Option<Vec<ServiceTime>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pastoral_staff: Option<Vec<StaffMember>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ministries: Option<Vec<Ministry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_settings: Option<AppSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emergency_contacts: Option<Vec<EmergencyContact>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coordinates: Option<Coordinates>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServiceTime {
pub day: String,
pub service: String,
pub time: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServiceTimes {
pub sabbath_school: Option<String>,
pub divine_worship: Option<String>,
pub prayer_meeting: Option<String>,
pub youth_service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_services: Option<Vec<SpecialService>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SpecialService {
pub name: String,
pub time: String,
pub frequency: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StaffMember {
pub name: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub responsibilities: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Ministry {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub leader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meeting_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meeting_location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
pub category: MinistryCategory,
#[serde(default)]
pub is_active: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EmergencyContact {
pub name: String,
pub title: String,
pub phone: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
pub priority: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub availability: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppSettings {
pub enable_notifications: bool,
pub enable_calendar_sync: bool,
pub enable_offline_mode: bool,
pub theme: AppTheme,
pub default_language: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub owncast_server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bible_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymnal_version: Option<String>,
pub cache_duration_minutes: u32,
pub auto_refresh_interval_minutes: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum MinistryCategory {
#[serde(rename = "worship")]
Worship,
#[serde(rename = "education")]
Education,
#[serde(rename = "youth")]
Youth,
#[serde(rename = "children")]
Children,
#[serde(rename = "outreach")]
Outreach,
#[serde(rename = "health")]
Health,
#[serde(rename = "music")]
Music,
#[serde(rename = "fellowship")]
Fellowship,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "stewardship")]
Stewardship,
#[serde(rename = "other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AppTheme {
#[serde(rename = "light")]
Light,
#[serde(rename = "dark")]
Dark,
#[serde(rename = "system")]
System,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
enable_notifications: true,
enable_calendar_sync: true,
enable_offline_mode: true,
theme: AppTheme::System,
default_language: "en".to_string(),
owncast_server: None,
bible_version: Some("KJV".to_string()),
hymnal_version: Some("1985".to_string()),
cache_duration_minutes: 60,
auto_refresh_interval_minutes: 15,
}
}
}
impl ChurchConfig {
pub fn get_display_name(&self) -> String {
self.church_name
.as_ref()
.cloned()
.unwrap_or_else(|| "Church".to_string())
}
pub fn has_social_media(&self) -> bool {
self.facebook_url.is_some() || self.youtube_url.is_some() || self.instagram_url.is_some()
}
pub fn get_contact_info(&self) -> Vec<(String, String)> {
let mut contacts = Vec::new();
if let Some(phone) = &self.contact_phone {
contacts.push(("Phone".to_string(), phone.clone()));
}
if let Some(email) = &self.contact_email {
contacts.push(("Email".to_string(), email.clone()));
}
if let Some(address) = &self.church_address {
contacts.push(("Address".to_string(), address.clone()));
}
if let Some(po_box) = &self.po_box {
contacts.push(("PO Box".to_string(), po_box.clone()));
}
contacts
}
pub fn active_ministries(&self) -> Vec<&Ministry> {
self.ministries
.as_ref()
.map(|ministries| {
ministries
.iter()
.filter(|ministry| ministry.is_active)
.collect()
})
.unwrap_or_default()
}
pub fn ministries_by_category(&self, category: MinistryCategory) -> Vec<&Ministry> {
self.ministries
.as_ref()
.map(|ministries| {
ministries
.iter()
.filter(|ministry| ministry.category == category && ministry.is_active)
.collect()
})
.unwrap_or_default()
}
pub fn emergency_contacts_by_priority(&self) -> Vec<&EmergencyContact> {
self.emergency_contacts
.as_ref()
.map(|contacts| {
let mut sorted = contacts.iter().collect::<Vec<_>>();
sorted.sort_by_key(|contact| contact.priority);
sorted
})
.unwrap_or_default()
}
}

339
src/models/contact.rs Normal file
View file

@ -0,0 +1,339 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContactForm {
pub name: String,
pub email: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub subject: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<ContactCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_contact_method: Option<ContactMethod>,
#[serde(skip_serializing_if = "Option::is_none")]
pub urgent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visitor_info: Option<VisitorInfo>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContactSubmission {
pub id: String,
pub name: String,
pub email: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub subject: String,
pub message: String,
pub category: ContactCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_contact_method: Option<ContactMethod>,
#[serde(default)]
pub urgent: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub visitor_info: Option<VisitorInfo>,
pub status: ContactStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub responded_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VisitorInfo {
pub is_first_time: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub how_heard_about_us: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interests: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub family_members: Option<Vec<FamilyMember>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prayer_requests: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<Address>,
#[serde(default)]
pub wants_follow_up: bool,
#[serde(default)]
pub wants_newsletter: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FamilyMember {
pub name: String,
pub relationship: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub age_group: Option<AgeGroup>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interests: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Address {
pub street: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zip_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PrayerRequest {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub request: String,
pub category: PrayerCategory,
#[serde(default)]
pub is_public: bool,
#[serde(default)]
pub is_urgent: bool,
#[serde(default)]
pub is_confidential: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub follow_up_requested: Option<bool>,
pub status: PrayerStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub answered_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum ContactCategory {
#[serde(rename = "general")]
General,
#[serde(rename = "pastoral_care")]
PastoralCare,
#[serde(rename = "prayer_request")]
PrayerRequest,
#[serde(rename = "visitor")]
Visitor,
#[serde(rename = "ministry")]
Ministry,
#[serde(rename = "event")]
Event,
#[serde(rename = "technical")]
Technical,
#[serde(rename = "feedback")]
Feedback,
#[serde(rename = "donation")]
Donation,
#[serde(rename = "membership")]
Membership,
#[serde(rename = "baptism")]
Baptism,
#[serde(rename = "wedding")]
Wedding,
#[serde(rename = "funeral")]
Funeral,
#[serde(rename = "other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum ContactMethod {
#[serde(rename = "email")]
Email,
#[serde(rename = "phone")]
Phone,
#[serde(rename = "text")]
Text,
#[serde(rename = "mail")]
Mail,
#[serde(rename = "no_preference")]
NoPreference,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum ContactStatus {
#[serde(rename = "new")]
New,
#[serde(rename = "assigned")]
Assigned,
#[serde(rename = "in_progress")]
InProgress,
#[serde(rename = "responded")]
Responded,
#[serde(rename = "follow_up")]
FollowUp,
#[serde(rename = "completed")]
Completed,
#[serde(rename = "closed")]
Closed,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum PrayerCategory {
#[serde(rename = "health")]
Health,
#[serde(rename = "family")]
Family,
#[serde(rename = "finances")]
Finances,
#[serde(rename = "relationships")]
Relationships,
#[serde(rename = "spiritual")]
Spiritual,
#[serde(rename = "work")]
Work,
#[serde(rename = "travel")]
Travel,
#[serde(rename = "community")]
Community,
#[serde(rename = "praise")]
Praise,
#[serde(rename = "other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum PrayerStatus {
#[serde(rename = "new")]
New,
#[serde(rename = "praying")]
Praying,
#[serde(rename = "answered")]
Answered,
#[serde(rename = "ongoing")]
Ongoing,
#[serde(rename = "closed")]
Closed,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AgeGroup {
#[serde(rename = "infant")]
Infant,
#[serde(rename = "toddler")]
Toddler,
#[serde(rename = "child")]
Child,
#[serde(rename = "youth")]
Youth,
#[serde(rename = "adult")]
Adult,
#[serde(rename = "senior")]
Senior,
}
impl ContactForm {
pub fn new(name: String, email: String, subject: String, message: String) -> Self {
Self {
name,
email,
phone: None,
subject,
message,
category: None,
preferred_contact_method: None,
urgent: None,
visitor_info: None,
}
}
pub fn with_category(mut self, category: ContactCategory) -> Self {
self.category = Some(category);
self
}
pub fn with_phone(mut self, phone: String) -> Self {
self.phone = Some(phone);
self
}
pub fn with_preferred_method(mut self, method: ContactMethod) -> Self {
self.preferred_contact_method = Some(method);
self
}
pub fn mark_urgent(mut self) -> Self {
self.urgent = Some(true);
self
}
pub fn with_visitor_info(mut self, visitor_info: VisitorInfo) -> Self {
self.visitor_info = Some(visitor_info);
self
}
pub fn is_urgent(&self) -> bool {
self.urgent.unwrap_or(false)
}
pub fn is_visitor(&self) -> bool {
self.visitor_info.is_some()
}
}
impl ContactSubmission {
pub fn is_urgent(&self) -> bool {
self.urgent
}
pub fn is_visitor(&self) -> bool {
self.visitor_info.is_some()
}
pub fn is_open(&self) -> bool {
!matches!(self.status, ContactStatus::Completed | ContactStatus::Closed)
}
pub fn needs_response(&self) -> bool {
matches!(self.status, ContactStatus::New | ContactStatus::Assigned)
}
pub fn response_time(&self) -> Option<chrono::Duration> {
self.responded_at.map(|responded| responded - self.created_at)
}
pub fn age_days(&self) -> i64 {
(Utc::now() - self.created_at).num_days()
}
}
impl PrayerRequest {
pub fn is_urgent(&self) -> bool {
self.is_urgent
}
pub fn is_confidential(&self) -> bool {
self.is_confidential
}
pub fn is_public(&self) -> bool {
self.is_public && !self.is_confidential
}
pub fn is_open(&self) -> bool {
!matches!(self.status, PrayerStatus::Answered | PrayerStatus::Closed)
}
pub fn is_answered(&self) -> bool {
matches!(self.status, PrayerStatus::Answered)
}
pub fn age_days(&self) -> i64 {
(Utc::now() - self.created_at).num_days()
}
}

343
src/models/event.rs Normal file
View file

@ -0,0 +1,343 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize, Deserializer};
use std::fmt;
/// Timezone-aware timestamp from v2 API
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TimezoneTimestamp {
pub utc: DateTime<Utc>,
pub local: String, // "2025-08-13T05:00:00-04:00"
pub timezone: String, // "America/New_York"
}
/// Custom deserializer that handles both v1 (simple string) and v2 (timezone object) formats
fn deserialize_flexible_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct FlexibleDateTimeVisitor;
impl<'de> Visitor<'de> for FlexibleDateTimeVisitor {
type Value = DateTime<Utc>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string timestamp or timezone object")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
// v1 format: simple ISO string
DateTime::parse_from_rfc3339(value)
.map(|dt| dt.with_timezone(&Utc))
.map_err(de::Error::custom)
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
// v2 format: timezone object - extract UTC field
let mut utc_value: Option<DateTime<Utc>> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"utc" => {
utc_value = Some(map.next_value()?);
}
_ => {
// Skip other fields (local, timezone)
let _: serde_json::Value = map.next_value()?;
}
}
}
utc_value.ok_or_else(|| de::Error::missing_field("utc"))
}
}
deserializer.deserialize_any(FlexibleDateTimeVisitor)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Event {
pub id: String,
pub title: String,
pub description: String,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub start_time: DateTime<Utc>,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub end_time: DateTime<Utc>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
pub category: EventCategory,
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_attendees: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_attendees: Option<u32>,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub created_at: DateTime<Utc>,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewEvent {
pub title: String,
pub description: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
pub category: EventCategory,
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_attendees: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EventUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<EventCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_featured: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_attendees: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum EventCategory {
#[serde(rename = "service", alias = "Service")]
Service,
#[serde(rename = "ministry", alias = "Ministry")]
Ministry,
#[serde(rename = "social", alias = "Social")]
Social,
#[serde(rename = "education", alias = "Education")]
Education,
#[serde(rename = "outreach", alias = "Outreach")]
Outreach,
#[serde(rename = "youth", alias = "Youth")]
Youth,
#[serde(rename = "music", alias = "Music")]
Music,
#[serde(rename = "other", alias = "Other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum RecurringType {
#[serde(rename = "daily", alias = "DAILY")]
Daily,
#[serde(rename = "weekly", alias = "WEEKLY")]
Weekly,
#[serde(rename = "biweekly", alias = "BIWEEKLY")]
Biweekly,
#[serde(rename = "monthly", alias = "MONTHLY")]
Monthly,
#[serde(rename = "first_tuesday", alias = "FIRST_TUESDAY")]
FirstTuesday,
#[serde(rename = "first_sabbath", alias = "FIRST_SABBATH")]
FirstSabbath,
#[serde(rename = "last_sabbath", alias = "LAST_SABBATH")]
LastSabbath,
}
impl Event {
pub fn duration_minutes(&self) -> i64 {
(self.end_time - self.start_time).num_minutes()
}
pub fn has_registration(&self) -> bool {
self.registration_url.is_some()
}
pub fn is_full(&self) -> bool {
match (self.max_attendees, self.current_attendees) {
(Some(max), Some(current)) => current >= max,
_ => false,
}
}
pub fn spots_remaining(&self) -> Option<u32> {
match (self.max_attendees, self.current_attendees) {
(Some(max), Some(current)) => Some(max.saturating_sub(current)),
_ => None,
}
}
}
impl fmt::Display for EventCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EventCategory::Service => write!(f, "Service"),
EventCategory::Ministry => write!(f, "Ministry"),
EventCategory::Social => write!(f, "Social"),
EventCategory::Education => write!(f, "Education"),
EventCategory::Outreach => write!(f, "Outreach"),
EventCategory::Youth => write!(f, "Youth"),
EventCategory::Music => write!(f, "Music"),
EventCategory::Other => write!(f, "Other"),
}
}
}
impl Event {
pub fn formatted_date(&self) -> String {
self.start_time.format("%A, %B %d, %Y").to_string()
}
/// Returns formatted date range for multi-day events, single date for same-day events
pub fn formatted_date_range(&self) -> String {
let start_date = self.start_time.date_naive();
let end_date = self.end_time.date_naive();
if start_date == end_date {
// Same day event
self.start_time.format("%A, %B %d, %Y").to_string()
} else {
// Multi-day event
let start_formatted = self.start_time.format("%A, %B %d, %Y").to_string();
let end_formatted = self.end_time.format("%A, %B %d, %Y").to_string();
format!("{} - {}", start_formatted, end_formatted)
}
}
pub fn formatted_start_time(&self) -> String {
// Convert UTC to user's local timezone automatically
let local_time = self.start_time.with_timezone(&chrono::Local);
local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string()
}
pub fn formatted_end_time(&self) -> String {
// Convert UTC to user's local timezone automatically
let local_time = self.end_time.with_timezone(&chrono::Local);
local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string()
}
pub fn clean_description(&self) -> String {
html2text::from_read(self.description.as_bytes(), 80)
.replace('\n', " ")
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
}
/// Event submission for public submission endpoint
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EventSubmission {
pub title: String,
pub description: String,
pub start_time: String, // ISO string format
pub end_time: String, // ISO string format
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
pub category: String, // String to match API exactly
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bulletin_week: Option<String>, // Date string in YYYY-MM-DD format
pub submitter_email: String,
}
impl EventSubmission {
/// Parse start_time string to DateTime<Utc>
pub fn parse_start_time(&self) -> Option<DateTime<Utc>> {
crate::utils::parse_datetime_flexible(&self.start_time)
}
/// Parse end_time string to DateTime<Utc>
pub fn parse_end_time(&self) -> Option<DateTime<Utc>> {
crate::utils::parse_datetime_flexible(&self.end_time)
}
/// Validate that both start and end times can be parsed
pub fn validate_times(&self) -> bool {
self.parse_start_time().is_some() && self.parse_end_time().is_some()
}
}
/// Pending event for admin management
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PendingEvent {
pub id: String,
pub title: String,
pub description: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
pub category: EventCategory,
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bulletin_week: Option<String>,
pub submitter_email: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

28
src/models/mod.rs Normal file
View file

@ -0,0 +1,28 @@
pub mod common;
pub mod event;
pub mod bulletin;
pub mod config;
pub mod contact;
pub mod sermon;
pub mod streaming;
pub mod auth;
pub mod bible;
pub mod client_models;
pub mod v2;
pub mod admin;
pub use common::*;
pub use event::*;
pub use bulletin::*;
pub use config::*;
pub use contact::*;
pub use sermon::*;
pub use streaming::*;
pub use auth::*;
pub use bible::*;
pub use client_models::*;
pub use v2::*;
pub use admin::*;
// Re-export livestream types from client module for convenience
pub use crate::client::livestream::{StreamStatus, LiveStream};

376
src/models/sermon.rs Normal file
View file

@ -0,0 +1,376 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// API response structure for sermons from the external API
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiSermon {
pub id: String,
pub title: String,
pub speaker: Option<String>,
pub date: Option<String>,
pub duration: String, // Duration as string like "1:13:01"
pub description: Option<String>,
pub audio_url: Option<String>,
pub video_url: Option<String>,
pub media_type: Option<String>,
pub thumbnail: Option<String>,
pub scripture_reading: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Sermon {
pub id: String,
pub title: String,
pub speaker: String,
pub description: String,
pub date: DateTime<Utc>,
pub scripture_reference: String,
pub series: Option<String>,
pub duration_string: Option<String>, // Raw duration from API (e.g., "2:34:49")
pub media_url: Option<String>,
pub audio_url: Option<String>,
pub video_url: Option<String>,
pub transcript: Option<String>,
pub thumbnail: Option<String>,
pub tags: Option<Vec<String>>,
pub category: SermonCategory,
pub is_featured: bool,
pub view_count: u32,
pub download_count: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewSermon {
pub title: String,
pub speaker: String,
pub description: String,
pub date: DateTime<Utc>,
pub scripture_reference: String,
pub series: Option<String>,
pub duration_string: Option<String>,
pub media_url: Option<String>,
pub audio_url: Option<String>,
pub video_url: Option<String>,
pub transcript: Option<String>,
pub thumbnail: Option<String>,
pub tags: Option<Vec<String>>,
pub category: SermonCategory,
pub is_featured: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonSeries {
pub id: String,
pub title: String,
pub description: String,
pub speaker: String,
pub start_date: DateTime<Utc>,
pub end_date: Option<DateTime<Utc>>,
pub thumbnail: Option<String>,
pub sermons: Vec<Sermon>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonNote {
pub id: String,
pub sermon_id: String,
pub user_id: String,
pub content: String,
pub timestamp_seconds: Option<u32>,
pub is_private: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonFeedback {
pub id: String,
pub sermon_id: String,
pub user_name: Option<String>,
pub user_email: Option<String>,
pub rating: Option<u8>, // 1-5 stars
pub comment: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum SermonCategory {
#[serde(rename = "regular")]
Regular,
#[serde(rename = "evangelistic")]
Evangelistic,
#[serde(rename = "youth")]
Youth,
#[serde(rename = "children")]
Children,
#[serde(rename = "special")]
Special,
#[serde(rename = "prophecy")]
Prophecy,
#[serde(rename = "health")]
Health,
#[serde(rename = "stewardship")]
Stewardship,
#[serde(rename = "testimony")]
Testimony,
#[serde(rename = "holiday")]
Holiday,
#[serde(rename = "communion")]
Communion,
#[serde(rename = "baptism")]
Baptism,
#[serde(rename = "wedding")]
Wedding,
#[serde(rename = "funeral")]
Funeral,
#[serde(rename = "other")]
Other,
#[serde(rename = "livestream_archive")]
LivestreamArchive,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonSearch {
pub query: Option<String>,
pub speaker: Option<String>,
pub category: Option<SermonCategory>,
pub series: Option<String>,
pub date_from: Option<DateTime<Utc>>,
pub date_to: Option<DateTime<Utc>>,
pub tags: Option<Vec<String>>,
pub featured_only: Option<bool>,
pub has_video: Option<bool>,
pub has_audio: Option<bool>,
pub has_transcript: Option<bool>,
pub min_duration: Option<u32>,
pub max_duration: Option<u32>,
}
impl Default for SermonSearch {
fn default() -> Self {
Self {
query: None,
speaker: None,
category: None,
series: None,
date_from: None,
date_to: None,
tags: None,
featured_only: None,
has_video: None,
has_audio: None,
has_transcript: None,
min_duration: None,
max_duration: None,
}
}
}
impl SermonSearch {
pub fn new() -> Self {
Self::default()
}
pub fn with_query(mut self, query: String) -> Self {
self.query = Some(query);
self
}
pub fn with_speaker(mut self, speaker: String) -> Self {
self.speaker = Some(speaker);
self
}
pub fn with_category(mut self, category: SermonCategory) -> Self {
self.category = Some(category);
self
}
pub fn with_series(mut self, series: String) -> Self {
self.series = Some(series);
self
}
pub fn with_date_range(mut self, from: DateTime<Utc>, to: DateTime<Utc>) -> Self {
self.date_from = Some(from);
self.date_to = Some(to);
self
}
pub fn featured_only(mut self) -> Self {
self.featured_only = Some(true);
self
}
pub fn with_video(mut self) -> Self {
self.has_video = Some(true);
self
}
pub fn with_audio(mut self) -> Self {
self.has_audio = Some(true);
self
}
pub fn with_transcript(mut self) -> Self {
self.has_transcript = Some(true);
self
}
}
impl Sermon {
pub fn duration_formatted(&self) -> String {
self.duration_string.clone().unwrap_or_else(|| "Unknown".to_string())
}
pub fn has_media(&self) -> bool {
self.media_url.is_some() || self.audio_url.is_some() || self.video_url.is_some()
}
pub fn has_video(&self) -> bool {
self.video_url.is_some() || self.media_url.is_some()
}
pub fn has_audio(&self) -> bool {
self.audio_url.is_some() || self.media_url.is_some()
}
pub fn has_transcript(&self) -> bool {
self.transcript.is_some()
}
pub fn is_recent(&self) -> bool {
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
self.date > thirty_days_ago
}
pub fn is_popular(&self) -> bool {
self.view_count > 100 || self.download_count > 50
}
pub fn get_tags(&self) -> Vec<String> {
self.tags.clone().unwrap_or_default()
}
pub fn matches_search(&self, search: &SermonSearch) -> bool {
if let Some(query) = &search.query {
let query_lower = query.to_lowercase();
if !self.title.to_lowercase().contains(&query_lower)
&& !self.description.to_lowercase().contains(&query_lower)
&& !self.speaker.to_lowercase().contains(&query_lower)
&& !self.scripture_reference.to_lowercase().contains(&query_lower) {
return false;
}
}
if let Some(speaker) = &search.speaker {
if !self.speaker.to_lowercase().contains(&speaker.to_lowercase()) {
return false;
}
}
if let Some(category) = &search.category {
if self.category != *category {
return false;
}
}
if let Some(series) = &search.series {
match &self.series {
Some(sermon_series) => {
if !sermon_series.to_lowercase().contains(&series.to_lowercase()) {
return false;
}
}
None => return false,
}
}
if let Some(date_from) = search.date_from {
if self.date < date_from {
return false;
}
}
if let Some(date_to) = search.date_to {
if self.date > date_to {
return false;
}
}
if let Some(true) = search.featured_only {
if !self.is_featured {
return false;
}
}
if let Some(true) = search.has_video {
if !self.has_video() {
return false;
}
}
if let Some(true) = search.has_audio {
if !self.has_audio() {
return false;
}
}
if let Some(true) = search.has_transcript {
if !self.has_transcript() {
return false;
}
}
true
}
}
impl SermonSeries {
pub fn is_active(&self) -> bool {
self.is_active && self.end_date.map_or(true, |end| end > Utc::now())
}
pub fn sermon_count(&self) -> usize {
self.sermons.len()
}
pub fn total_duration(&self) -> Option<u32> {
if self.sermons.is_empty() {
return None;
}
// Since we now use duration_string, we can't easily sum durations
// Return None for now - this would need proper duration parsing if needed
None
}
pub fn latest_sermon(&self) -> Option<&Sermon> {
self.sermons
.iter()
.max_by_key(|s| s.date)
}
pub fn duration_formatted(&self) -> String {
match self.total_duration() {
Some(seconds) => {
let minutes = seconds / 60;
let hours = minutes / 60;
let remaining_minutes = minutes % 60;
if hours > 0 {
format!("{}h {}m", hours, remaining_minutes)
} else {
format!("{}m", minutes)
}
}
None => "Unknown".to_string(),
}
}
}

157
src/models/streaming.rs Normal file
View file

@ -0,0 +1,157 @@
use serde::{Deserialize, Serialize};
/// Device streaming capabilities
#[derive(Debug, Clone, PartialEq)]
pub enum StreamingCapability {
/// Device supports AV1 codec (direct stream)
AV1,
/// Device needs HLS H.264 fallback
HLS,
}
/// Streaming URL configuration
#[derive(Debug, Clone)]
pub struct StreamingUrl {
pub url: String,
pub capability: StreamingCapability,
}
/// Device capability detection
pub struct DeviceCapabilities;
impl DeviceCapabilities {
/// Detect device streaming capability
/// For now, this is a simple implementation that can be expanded
#[cfg(target_os = "ios")]
pub fn detect_capability() -> StreamingCapability {
// Use sysctlbyname to get device model on iOS
use std::ffi::{CStr, CString};
use std::mem;
unsafe {
let name = CString::new("hw.model").unwrap();
let mut size: libc::size_t = 0;
// First call to get the size
if libc::sysctlbyname(
name.as_ptr(),
std::ptr::null_mut(),
&mut size,
std::ptr::null_mut(),
0,
) != 0 {
println!("🎬 DEBUG: Failed to get model size, defaulting to HLS");
return StreamingCapability::HLS;
}
// Allocate buffer and get the actual value
let mut buffer = vec![0u8; size];
if libc::sysctlbyname(
name.as_ptr(),
buffer.as_mut_ptr() as *mut libc::c_void,
&mut size,
std::ptr::null_mut(),
0,
) != 0 {
println!("🎬 DEBUG: Failed to get model value, defaulting to HLS");
return StreamingCapability::HLS;
}
// Convert to string
if let Ok(model_cstr) = CStr::from_bytes_with_nul(&buffer[..size]) {
if let Ok(model) = model_cstr.to_str() {
let model = model.to_lowercase();
println!("🎬 DEBUG: Detected device model: {}", model);
// iPhone models with AV1 hardware decoding support:
// Marketing names: iPhone16,x = iPhone 15 Pro/Pro Max, iPhone17,x = iPhone 16 series
// Internal codenames: d9xap = iPhone 16 series, d8xap = iPhone 15 Pro series
if model.starts_with("iphone16,") || model.starts_with("iphone17,") ||
model.starts_with("d94ap") || model.starts_with("d93ap") ||
model.starts_with("d84ap") || model.starts_with("d83ap") {
println!("🎬 DEBUG: Device {} supports AV1 hardware decoding", model);
return StreamingCapability::AV1;
}
println!("🎬 DEBUG: Device {} does not support AV1, using HLS fallback", model);
return StreamingCapability::HLS;
}
}
println!("🎬 DEBUG: Failed to parse model string, defaulting to HLS");
StreamingCapability::HLS
}
}
#[cfg(not(target_os = "ios"))]
pub fn detect_capability() -> StreamingCapability {
// Default to HLS for other platforms for now
StreamingCapability::HLS
}
/// Generate streaming URL based on capability and media ID
pub fn get_streaming_url(base_url: &str, media_id: &str, capability: StreamingCapability) -> StreamingUrl {
// Add timestamp for cache busting to ensure fresh streams
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let url = match capability {
StreamingCapability::AV1 => {
format!("{}/api/media/stream/{}?t={}", base_url.trim_end_matches('/'), media_id, timestamp)
}
StreamingCapability::HLS => {
format!("{}/api/media/stream/{}/playlist.m3u8?t={}", base_url.trim_end_matches('/'), media_id, timestamp)
}
};
StreamingUrl { url, capability }
}
/// Get optimal streaming URL for current device
pub fn get_optimal_streaming_url(base_url: &str, media_id: &str) -> StreamingUrl {
let capability = Self::detect_capability();
Self::get_streaming_url(base_url, media_id, capability)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_av1_url_generation() {
let url = DeviceCapabilities::get_streaming_url(
"https://api.rockvilletollandsda.church",
"test-id-123",
StreamingCapability::AV1
);
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123");
assert_eq!(url.capability, StreamingCapability::AV1);
}
#[test]
fn test_hls_url_generation() {
let url = DeviceCapabilities::get_streaming_url(
"https://api.rockvilletollandsda.church",
"test-id-123",
StreamingCapability::HLS
);
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123/playlist.m3u8");
assert_eq!(url.capability, StreamingCapability::HLS);
}
#[test]
fn test_base_url_trimming() {
let url = DeviceCapabilities::get_streaming_url(
"https://api.rockvilletollandsda.church/",
"test-id-123",
StreamingCapability::HLS
);
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123/playlist.m3u8");
}
}

15
src/models/v2.rs Normal file
View file

@ -0,0 +1,15 @@
/// API version enum to specify which API version to use
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiVersion {
V1,
V2,
}
impl ApiVersion {
pub fn path_prefix(&self) -> &'static str {
match self {
ApiVersion::V1 => "",
ApiVersion::V2 => "v2/",
}
}
}

1726
src/uniffi_wrapper.rs Normal file

File diff suppressed because it is too large Load diff

310
src/utils/feed.rs Normal file
View file

@ -0,0 +1,310 @@
use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
use chrono::{DateTime, Utc, NaiveDateTime};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedItem {
pub id: String,
pub feed_type: FeedItemType,
pub timestamp: String, // ISO8601 format
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum FeedItemType {
#[serde(rename = "event")]
Event {
event: ClientEvent,
},
#[serde(rename = "sermon")]
Sermon {
sermon: Sermon,
},
#[serde(rename = "bulletin")]
Bulletin {
bulletin: Bulletin,
},
#[serde(rename = "verse")]
Verse {
verse: BibleVerse,
},
}
/// Parse date string to DateTime<Utc>, with fallback to current time
fn parse_date_with_fallback(date_str: &str) -> DateTime<Utc> {
// Try ISO8601 format first
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
return dt.with_timezone(&Utc);
}
// Try naive datetime parsing
if let Ok(naive) = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
return DateTime::from_naive_utc_and_offset(naive, Utc);
}
// Fallback to current time
Utc::now()
}
/// Calculate priority for feed items based on type and recency
fn calculate_priority(feed_type: &FeedItemType, timestamp: &DateTime<Utc>) -> i32 {
let now = Utc::now();
let age_days = (now - *timestamp).num_days().max(0);
match feed_type {
FeedItemType::Event { .. } => {
// Events get highest priority, especially upcoming ones
if *timestamp > now {
1000 // Future events (upcoming)
} else {
800 - (age_days as i32) // Recent past events
}
},
FeedItemType::Sermon { .. } => {
// Sermons get high priority when recent
600 - (age_days as i32)
},
FeedItemType::Bulletin { .. } => {
// Bulletins get medium priority
400 - (age_days as i32)
},
FeedItemType::Verse { .. } => {
// Daily verse always gets consistent priority
300
},
}
}
/// Aggregate and sort home feed items
pub fn aggregate_home_feed(
events: &[ClientEvent],
sermons: &[Sermon],
bulletins: &[Bulletin],
daily_verse: Option<&BibleVerse>
) -> Vec<FeedItem> {
let mut feed_items = Vec::new();
// Add recent sermons (limit to 3)
for sermon in sermons.iter().take(3) {
let timestamp = sermon.date; // Already a DateTime<Utc>
let feed_type = FeedItemType::Sermon { sermon: sermon.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("sermon_{}", sermon.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add upcoming events (limit to 2)
for event in events.iter().take(2) {
let timestamp = parse_date_with_fallback(&event.created_at);
let feed_type = FeedItemType::Event { event: event.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("event_{}", event.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add most recent bulletin
if let Some(bulletin) = bulletins.first() {
let timestamp = parse_date_with_fallback(&bulletin.date.to_string());
let feed_type = FeedItemType::Bulletin { bulletin: bulletin.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("bulletin_{}", bulletin.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add daily verse
if let Some(verse) = daily_verse {
let timestamp = Utc::now();
let feed_type = FeedItemType::Verse { verse: verse.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("verse_{}", verse.reference),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Sort by priority (highest first), then by timestamp (newest first)
feed_items.sort_by(|a, b| {
b.priority.cmp(&a.priority)
.then_with(|| b.timestamp.cmp(&a.timestamp))
});
feed_items
}
/// Media type enumeration for content categorization
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MediaType {
Sermons,
LiveStreams,
}
impl MediaType {
pub fn display_name(&self) -> &'static str {
match self {
MediaType::Sermons => "Sermons",
MediaType::LiveStreams => "Live Archives",
}
}
pub fn icon_name(&self) -> &'static str {
match self {
MediaType::Sermons => "play.rectangle.fill",
MediaType::LiveStreams => "dot.radiowaves.left.and.right",
}
}
}
/// Get sermons or livestreams based on media type
pub fn get_media_content(sermons: &[Sermon], media_type: &MediaType) -> Vec<Sermon> {
match media_type {
MediaType::Sermons => {
// Filter for regular sermons (non-livestream)
sermons.iter()
.filter(|sermon| !sermon.title.to_lowercase().contains("livestream"))
.cloned()
.collect()
},
MediaType::LiveStreams => {
// Filter for livestream archives
sermons.iter()
.filter(|sermon| sermon.title.to_lowercase().contains("livestream"))
.cloned()
.collect()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
fn create_sample_event(id: &str, title: &str) -> ClientEvent {
ClientEvent {
id: id.to_string(),
title: title.to_string(),
description: "Sample description".to_string(),
date: "2025-01-15".to_string(),
start_time: "6:00 PM".to_string(),
end_time: "8:00 PM".to_string(),
location: "Sample Location".to_string(),
location_url: None,
image: None,
thumbnail: None,
category: "Social".to_string(),
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
current_attendees: None,
created_at: "2025-01-10T10:00:00Z".to_string(),
updated_at: "2025-01-10T10:00:00Z".to_string(),
duration_minutes: 120,
has_registration: false,
is_full: false,
spots_remaining: None,
}
}
fn create_sample_sermon(id: &str, title: &str) -> Sermon {
Sermon {
id: id.to_string(),
title: title.to_string(),
description: Some("Sample sermon".to_string()),
date: Some("2025-01-10T10:00:00Z".to_string()),
video_url: Some("https://example.com/video".to_string()),
audio_url: None,
thumbnail_url: None,
duration: None,
speaker: Some("Pastor Smith".to_string()),
series: None,
scripture_references: None,
tags: None,
}
}
#[test]
fn test_aggregate_home_feed() {
let events = vec![
create_sample_event("1", "Event 1"),
create_sample_event("2", "Event 2"),
];
let sermons = vec![
create_sample_sermon("1", "Sermon 1"),
create_sample_sermon("2", "Sermon 2"),
];
let bulletins = vec![
Bulletin {
id: "1".to_string(),
title: "Weekly Bulletin".to_string(),
date: "2025-01-12T10:00:00Z".to_string(),
pdf_url: "https://example.com/bulletin.pdf".to_string(),
description: Some("This week's bulletin".to_string()),
thumbnail_url: None,
}
];
let verse = BibleVerse {
text: "For God so loved the world...".to_string(),
reference: "John 3:16".to_string(),
version: Some("KJV".to_string()),
};
let feed = aggregate_home_feed(&events, &sermons, &bulletins, Some(&verse));
assert!(feed.len() >= 4); // Should have events, sermons, bulletin, and verse
// Check that items are sorted by priority
for i in 1..feed.len() {
assert!(feed[i-1].priority >= feed[i].priority);
}
}
#[test]
fn test_media_type_display() {
assert_eq!(MediaType::Sermons.display_name(), "Sermons");
assert_eq!(MediaType::LiveStreams.display_name(), "Live Archives");
assert_eq!(MediaType::Sermons.icon_name(), "play.rectangle.fill");
assert_eq!(MediaType::LiveStreams.icon_name(), "dot.radiowaves.left.and.right");
}
#[test]
fn test_get_media_content() {
let sermons = vec![
create_sample_sermon("1", "Regular Sermon"),
create_sample_sermon("2", "Livestream Service"),
create_sample_sermon("3", "Another Sermon"),
];
let regular_sermons = get_media_content(&sermons, &MediaType::Sermons);
assert_eq!(regular_sermons.len(), 2);
let livestreams = get_media_content(&sermons, &MediaType::LiveStreams);
assert_eq!(livestreams.len(), 1);
assert!(livestreams[0].title.contains("Livestream"));
}
}

159
src/utils/formatting.rs Normal file
View file

@ -0,0 +1,159 @@
use crate::models::ClientEvent;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormattedEvent {
pub formatted_time: String,
pub formatted_date_time: String,
pub is_multi_day: bool,
pub formatted_date_range: String,
}
/// Format time range for display
pub fn format_time_range(start_time: &str, end_time: &str) -> String {
format!("{} - {}", start_time, end_time)
}
/// Check if event appears to be multi-day based on date format
pub fn is_multi_day_event(date: &str) -> bool {
date.contains(" - ")
}
/// Format date and time for display, handling multi-day events
pub fn format_date_time(date: &str, start_time: &str, end_time: &str) -> String {
if is_multi_day_event(date) {
// For multi-day events, integrate times with their respective dates
let components: Vec<&str> = date.split(" - ").collect();
if components.len() == 2 {
format!("{} at {} - {} at {}", components[0], start_time, components[1], end_time)
} else {
date.to_string() // Fallback to original date
}
} else {
// Single day events: return just the date (time displayed separately)
date.to_string()
}
}
/// Format a client event with all display formatting logic
pub fn format_event_for_display(event: &ClientEvent) -> FormattedEvent {
let start_time = &event.start_time;
let end_time = &event.end_time;
// Derive formatted date from start_time since ClientEvent no longer has date field
let formatted_date = format_date_from_timestamp(start_time);
FormattedEvent {
formatted_time: format_time_range(start_time, end_time),
formatted_date_time: format_date_time(&formatted_date, start_time, end_time),
is_multi_day: is_multi_day_event(&formatted_date),
formatted_date_range: formatted_date,
}
}
/// Extract formatted date from ISO timestamp
fn format_date_from_timestamp(timestamp: &str) -> String {
use chrono::{DateTime, FixedOffset};
if let Ok(dt) = DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%z") {
dt.format("%A, %B %d, %Y").to_string()
} else {
"Date TBD".to_string()
}
}
/// Format duration in minutes to human readable format
pub fn format_duration_minutes(minutes: i64) -> String {
if minutes < 60 {
format!("{} min", minutes)
} else {
let hours = minutes / 60;
let remaining_minutes = minutes % 60;
if remaining_minutes == 0 {
format!("{} hr", hours)
} else {
format!("{} hr {} min", hours, remaining_minutes)
}
}
}
/// Format spots remaining for events
pub fn format_spots_remaining(current: Option<u32>, max: Option<u32>) -> Option<String> {
match (current, max) {
(Some(current), Some(max)) => {
let remaining = max.saturating_sub(current);
if remaining == 0 {
Some("Event Full".to_string())
} else {
Some(format!("{} spots remaining", remaining))
}
}
_ => None,
}
}
/// Check if event registration is full
pub fn is_event_full(current: Option<u32>, max: Option<u32>) -> bool {
match (current, max) {
(Some(current), Some(max)) => current >= max,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time_range() {
assert_eq!(format_time_range("9:00 AM", "5:00 PM"), "9:00 AM - 5:00 PM");
assert_eq!(format_time_range("", ""), " - ");
}
#[test]
fn test_is_multi_day_event() {
assert!(is_multi_day_event("Saturday, Aug 30, 2025 - Sunday, Aug 31, 2025"));
assert!(!is_multi_day_event("Saturday, Aug 30, 2025"));
assert!(!is_multi_day_event(""));
}
#[test]
fn test_format_date_time() {
// Single day event
let result = format_date_time("Saturday, Aug 30, 2025", "6:00 PM", "8:00 PM");
assert_eq!(result, "Saturday, Aug 30, 2025");
// Multi-day event
let result = format_date_time(
"Saturday, Aug 30, 2025 - Sunday, Aug 31, 2025",
"6:00 PM",
"6:00 AM"
);
assert_eq!(result, "Saturday, Aug 30, 2025 at 6:00 PM - Sunday, Aug 31, 2025 at 6:00 AM");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration_minutes(30), "30 min");
assert_eq!(format_duration_minutes(60), "1 hr");
assert_eq!(format_duration_minutes(90), "1 hr 30 min");
assert_eq!(format_duration_minutes(120), "2 hr");
}
#[test]
fn test_format_spots_remaining() {
assert_eq!(format_spots_remaining(Some(8), Some(10)), Some("2 spots remaining".to_string()));
assert_eq!(format_spots_remaining(Some(10), Some(10)), Some("Event Full".to_string()));
assert_eq!(format_spots_remaining(None, Some(10)), None);
assert_eq!(format_spots_remaining(Some(5), None), None);
}
#[test]
fn test_is_event_full() {
assert!(is_event_full(Some(10), Some(10)));
assert!(is_event_full(Some(11), Some(10))); // Over capacity
assert!(!is_event_full(Some(9), Some(10)));
assert!(!is_event_full(None, Some(10)));
assert!(!is_event_full(Some(5), None));
}
}

9
src/utils/mod.rs Normal file
View file

@ -0,0 +1,9 @@
pub mod scripture;
pub mod validation;
pub mod formatting;
pub mod feed;
pub use scripture::*;
pub use validation::*;
pub use formatting::*;
pub use feed::*;

164
src/utils/scripture.rs Normal file
View file

@ -0,0 +1,164 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptureSection {
pub verse: String,
pub reference: String,
}
/// Format raw scripture text into structured sections with verses and references
pub fn format_scripture_text(text: &str) -> Vec<ScriptureSection> {
// Handle single-line format where verse and reference are together
if text.contains(" KJV") && !text.contains('\n') {
// Single line format: "verse text. Book chapter:verse KJV"
if let Some(kjv_pos) = text.rfind(" KJV") {
let before_kjv = &text[..kjv_pos];
// Find the last period or other punctuation that separates verse from reference
if let Some(last_period) = before_kjv.rfind('.') {
if let Some(reference_start) = before_kjv[last_period..].find(char::is_alphabetic) {
let actual_start = last_period + reference_start;
let verse_text = format!("{}.", &before_kjv[..last_period]);
let reference = format!("{} KJV", &before_kjv[actual_start..]);
return vec![ScriptureSection {
verse: verse_text.trim().to_string(),
reference: reference.trim().to_string(),
}];
}
}
}
// Fallback: treat entire text as verse with no separate reference
return vec![ScriptureSection {
verse: text.to_string(),
reference: String::new(),
}];
}
// Multi-line format (original logic)
let sections: Vec<&str> = text.split('\n').collect();
let mut formatted_sections = Vec::new();
let mut current_verse = String::new();
for section in sections {
let trimmed = section.trim();
if trimmed.is_empty() {
continue;
}
// Check if this line is a reference (contains "KJV" at the end)
if trimmed.ends_with("KJV") {
// This is a reference for the verse we just accumulated
if !current_verse.is_empty() {
formatted_sections.push(ScriptureSection {
verse: current_verse.clone(),
reference: trimmed.to_string(),
});
current_verse.clear(); // Reset for next verse
}
} else {
// This is verse text
if !current_verse.is_empty() {
current_verse.push(' ');
}
current_verse.push_str(trimmed);
}
}
// Add any remaining verse without a reference
if !current_verse.is_empty() {
formatted_sections.push(ScriptureSection {
verse: current_verse,
reference: String::new(),
});
}
formatted_sections
}
/// Extract scripture references from text (e.g., "Joel 2:28 KJV" patterns)
pub fn extract_scripture_references(text: &str) -> String {
let pattern = r"([1-3]?\s*[A-Za-z]+\s+\d+:\d+(?:-\d+)?)\s+KJV";
match Regex::new(pattern) {
Ok(regex) => {
let references: Vec<String> = regex
.captures_iter(text)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().trim().to_string()))
.collect();
if references.is_empty() {
"Scripture Reading".to_string()
} else {
references.join(", ")
}
}
Err(_) => "Scripture Reading".to_string(),
}
}
/// Create standardized share text for sermons
pub fn create_sermon_share_text(title: &str, speaker: &str, video_url: Option<&str>, audio_url: Option<&str>) -> Vec<String> {
let mut items = Vec::new();
// Create share text
let share_text = format!("Check out this sermon: \"{}\" by {}", title, speaker);
items.push(share_text);
// Add video URL if available, otherwise audio URL
if let Some(url) = video_url {
items.push(url.to_string());
} else if let Some(url) = audio_url {
items.push(url.to_string());
}
items
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_line_scripture_format() {
let input = "And it shall come to pass afterward, that I will pour out my spirit upon all flesh. Joel 2:28 KJV";
let result = format_scripture_text(input);
assert_eq!(result.len(), 1);
assert_eq!(result[0].verse, "And it shall come to pass afterward, that I will pour out my spirit upon all flesh.");
assert_eq!(result[0].reference, "Joel 2:28 KJV");
}
#[test]
fn test_multi_line_scripture_format() {
let input = "And it shall come to pass afterward, that I will pour out my spirit upon all flesh\nJoel 2:28 KJV\nQuench not the Spirit. Despise not prophesyings.\n1 Thessalonians 5:19-21 KJV";
let result = format_scripture_text(input);
assert_eq!(result.len(), 2);
assert_eq!(result[0].verse, "And it shall come to pass afterward, that I will pour out my spirit upon all flesh");
assert_eq!(result[0].reference, "Joel 2:28 KJV");
assert_eq!(result[1].verse, "Quench not the Spirit. Despise not prophesyings.");
assert_eq!(result[1].reference, "1 Thessalonians 5:19-21 KJV");
}
#[test]
fn test_extract_scripture_references() {
let input = "Some text with Joel 2:28 KJV and 1 Thessalonians 5:19-21 KJV references";
let result = extract_scripture_references(input);
assert_eq!(result, "Joel 2:28, 1 Thessalonians 5:19-21");
}
#[test]
fn test_create_sermon_share_text() {
let result = create_sermon_share_text(
"Test Sermon",
"John Doe",
Some("https://example.com/video"),
Some("https://example.com/audio")
);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "Check out this sermon: \"Test Sermon\" by John Doe");
assert_eq!(result[1], "https://example.com/video");
}
}

249
src/utils/validation.rs Normal file
View file

@ -0,0 +1,249 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc, NaiveDateTime};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<String>,
}
impl ValidationResult {
pub fn valid() -> Self {
Self {
is_valid: true,
errors: Vec::new(),
}
}
pub fn invalid(errors: Vec<String>) -> Self {
Self {
is_valid: false,
errors,
}
}
pub fn add_error(&mut self, error: String) {
self.errors.push(error);
self.is_valid = false;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactFormData {
pub name: String,
pub email: String,
pub phone: String,
pub message: String,
pub subject: String,
}
/// Validate email address using regex
pub fn is_valid_email(email: &str) -> bool {
let email_regex = Regex::new(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$").unwrap();
email_regex.is_match(email)
}
/// Validate phone number - must be exactly 10 digits
pub fn is_valid_phone(phone: &str) -> bool {
let digits_only: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
digits_only.len() == 10
}
/// Validate contact form with all business rules
pub fn validate_contact_form(form_data: &ContactFormData) -> ValidationResult {
let mut errors = Vec::new();
let trimmed_name = form_data.name.trim();
let trimmed_email = form_data.email.trim();
let trimmed_phone = form_data.phone.trim();
let trimmed_message = form_data.message.trim();
// Name validation
if trimmed_name.is_empty() || trimmed_name.len() < 2 {
errors.push("Name must be at least 2 characters".to_string());
}
// Email validation
if trimmed_email.is_empty() {
errors.push("Email is required".to_string());
} else if !is_valid_email(trimmed_email) {
errors.push("Please enter a valid email address".to_string());
}
// Phone validation (optional, but if provided must be valid)
if !trimmed_phone.is_empty() && !is_valid_phone(trimmed_phone) {
errors.push("Please enter a valid phone number".to_string());
}
// Message validation
if trimmed_message.is_empty() {
errors.push("Message is required".to_string());
} else if trimmed_message.len() < 10 {
errors.push("Message must be at least 10 characters".to_string());
}
if errors.is_empty() {
ValidationResult::valid()
} else {
ValidationResult::invalid(errors)
}
}
/// Sanitize and trim form input
pub fn sanitize_form_input(input: &str) -> String {
input.trim().to_string()
}
/// Parse date from various frontend formats
/// Supports: "2025-06-28T23:00", "2025-06-28 23:00", and RFC3339 formats
pub fn parse_datetime_flexible(date_str: &str) -> Option<DateTime<Utc>> {
let trimmed = date_str.trim();
// First try RFC3339/ISO 8601 with timezone info
if trimmed.contains('Z') || trimmed.contains('+') || trimmed.contains('-') && trimmed.rfind('-').map_or(false, |i| i > 10) {
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return Some(dt.with_timezone(&Utc));
}
// Try ISO 8601 with 'Z' suffix
if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.3fZ") {
return Some(dt.with_timezone(&Utc));
}
// Try ISO 8601 without milliseconds but with Z
if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ") {
return Some(dt.with_timezone(&Utc));
}
}
// Try local datetime formats (no timezone info) - treat as UTC
let local_formats = [
"%Y-%m-%dT%H:%M:%S%.3f", // ISO 8601 with milliseconds, no timezone
"%Y-%m-%dT%H:%M:%S", // ISO 8601 no milliseconds, no timezone
"%Y-%m-%dT%H:%M", // ISO 8601 no seconds, no timezone (frontend format)
"%Y-%m-%d %H:%M:%S", // Space separated with seconds
"%Y-%m-%d %H:%M", // Space separated no seconds
"%m/%d/%Y %H:%M:%S", // US format with seconds
"%m/%d/%Y %H:%M", // US format no seconds
];
for format in &local_formats {
if let Ok(naive_dt) = NaiveDateTime::parse_from_str(trimmed, format) {
return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
}
}
// Try date-only formats (no time) - set time to midnight UTC
let date_formats = [
"%Y-%m-%d", // ISO date
"%m/%d/%Y", // US date
"%d/%m/%Y", // European date
];
for format in &date_formats {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, format) {
return Some(DateTime::from_naive_utc_and_offset(
naive_date.and_hms_opt(0, 0, 0).unwrap(),
Utc,
));
}
}
None
}
/// Validate datetime string can be parsed
pub fn is_valid_datetime(datetime_str: &str) -> bool {
parse_datetime_flexible(datetime_str).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_email() {
assert!(is_valid_email("test@example.com"));
assert!(is_valid_email("user.name+tag@domain.co.uk"));
assert!(!is_valid_email("invalid.email"));
assert!(!is_valid_email("@domain.com"));
assert!(!is_valid_email("user@"));
}
#[test]
fn test_valid_phone() {
assert!(is_valid_phone("1234567890"));
assert!(is_valid_phone("(123) 456-7890"));
assert!(is_valid_phone("123-456-7890"));
assert!(!is_valid_phone("12345"));
assert!(!is_valid_phone("12345678901"));
assert!(!is_valid_phone("abc1234567"));
}
#[test]
fn test_contact_form_validation() {
let valid_form = ContactFormData {
name: "John Doe".to_string(),
email: "john@example.com".to_string(),
phone: "1234567890".to_string(),
message: "This is a test message with enough characters.".to_string(),
subject: "Test Subject".to_string(),
};
let result = validate_contact_form(&valid_form);
assert!(result.is_valid);
assert!(result.errors.is_empty());
let invalid_form = ContactFormData {
name: "A".to_string(), // Too short
email: "invalid-email".to_string(), // Invalid email
phone: "123".to_string(), // Invalid phone
message: "Short".to_string(), // Too short message
subject: "".to_string(),
};
let result = validate_contact_form(&invalid_form);
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 4);
}
#[test]
fn test_parse_datetime_flexible() {
// Test frontend format (the main case we're solving)
assert!(parse_datetime_flexible("2025-06-28T23:00").is_some());
// Test RFC3339 with Z
assert!(parse_datetime_flexible("2024-01-15T14:30:00Z").is_some());
// Test RFC3339 with timezone offset
assert!(parse_datetime_flexible("2024-01-15T14:30:00-05:00").is_some());
// Test ISO 8601 without timezone (should work as local time)
assert!(parse_datetime_flexible("2024-01-15T14:30:00").is_some());
// Test with milliseconds
assert!(parse_datetime_flexible("2024-01-15T14:30:00.000Z").is_some());
// Test space separated
assert!(parse_datetime_flexible("2024-01-15 14:30:00").is_some());
// Test date only
assert!(parse_datetime_flexible("2024-01-15").is_some());
// Test US format
assert!(parse_datetime_flexible("01/15/2024 14:30").is_some());
// Test invalid format
assert!(parse_datetime_flexible("invalid-date").is_none());
assert!(parse_datetime_flexible("").is_none());
}
#[test]
fn test_is_valid_datetime() {
assert!(is_valid_datetime("2025-06-28T23:00"));
assert!(is_valid_datetime("2024-01-15T14:30:00Z"));
assert!(!is_valid_datetime("invalid-date"));
assert!(!is_valid_datetime(""));
}
}

138
swift_usage_example.swift Normal file
View file

@ -0,0 +1,138 @@
// Swift Usage Example for New Config Functions
// This shows how to use the new RTSDA-compliant config functions in iOS
import Foundation
// WRONG - Old way (violates RTSDA architecture)
/*
private func loadChurchConfig() {
let configJson = fetchConfigJson() // Gets raw JSON from Rust
// THIS IS BUSINESS LOGIC AND SHOULD BE IN RUST!
if let configData = configJson.data(using: .utf8),
let config = try? JSONDecoder().decode(ChurchConfig.self, from: configData) {
self.churchConfig = config
} else {
print("Failed to load church config")
}
}
*/
// RIGHT - New way (follows RTSDA architecture)
class ChurchConfigManager: ObservableObject {
@Published var churchName: String = ""
@Published var contactPhone: String = ""
@Published var contactEmail: String = ""
@Published var brandColor: String = ""
@Published var aboutText: String = ""
@Published var donationUrl: String = ""
@Published var churchAddress: String = ""
@Published var coordinates: [Double] = [0.0, 0.0]
@Published var websiteUrl: String = ""
@Published var facebookUrl: String = ""
@Published var youtubeUrl: String = ""
@Published var instagramUrl: String = ""
@Published var missionStatement: String = ""
func loadConfig() {
// ALL business logic (JSON parsing, error handling, fallbacks) is in Rust
// Swift just calls Rust functions and gets parsed values directly
self.churchName = getChurchName()
self.contactPhone = getContactPhone()
self.contactEmail = getContactEmail()
self.brandColor = getBrandColor()
self.aboutText = getAboutText()
self.donationUrl = getDonationUrl()
self.churchAddress = getChurchAddress()
self.coordinates = getCoordinates()
self.websiteUrl = getWebsiteUrl()
self.facebookUrl = getFacebookUrl()
self.youtubeUrl = getYoutubeUrl()
self.instagramUrl = getInstagramUrl()
self.missionStatement = getMissionStatement()
}
}
// Example SwiftUI View
struct ConfigDisplayView: View {
@StateObject private var configManager = ChurchConfigManager()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(configManager.churchName)
.font(.title)
.foregroundColor(Color(hex: configManager.brandColor))
Text(configManager.aboutText)
.font(.body)
Text("Contact: \(configManager.contactPhone)")
Text("Email: \(configManager.contactEmail)")
Text("Address: \(configManager.churchAddress)")
if !configManager.donationUrl.isEmpty {
Link("Donate", destination: URL(string: configManager.donationUrl)!)
.foregroundColor(Color(hex: configManager.brandColor))
}
}
.padding()
.onAppear {
configManager.loadConfig()
}
}
}
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
/*
Benefits of this approach:
1. RTSDA Compliant - ALL business logic in Rust
2. No JSON parsing in Swift - Rust handles it all
3. Proper error handling with fallbacks in Rust
4. Consistent behavior across iOS and Android
5. Easy to test - just call Rust functions
6. No model synchronization issues
7. Automatic fallback values if config fails to load
8. Cleaner Swift code - just UI logic
Functions available:
- getChurchName() -> String
- getContactPhone() -> String
- getContactEmail() -> String
- getBrandColor() -> String
- getAboutText() -> String
- getDonationUrl() -> String
- getChurchAddress() -> String
- getCoordinates() -> [Double] // [latitude, longitude]
- getWebsiteUrl() -> String
- getFacebookUrl() -> String
- getYoutubeUrl() -> String
- getInstagramUrl() -> String
- getMissionStatement() -> String
*/

39
test_config_functions.rs Normal file
View file

@ -0,0 +1,39 @@
// Test script for new config functions
use church_core::uniffi_wrapper::{
get_church_name,
get_contact_phone,
get_contact_email,
get_brand_color,
get_about_text,
get_donation_url,
get_church_address,
get_coordinates,
get_website_url,
get_facebook_url,
get_youtube_url,
get_instagram_url,
get_mission_statement,
};
fn main() {
println!("🔍 Testing church-core config functions...\n");
println!("Church Name: {}", get_church_name());
println!("Contact Phone: {}", get_contact_phone());
println!("Contact Email: {}", get_contact_email());
println!("Brand Color: {}", get_brand_color());
println!("About Text: {}", get_about_text());
println!("Donation URL: {}", get_donation_url());
println!("Church Address: {}", get_church_address());
let coords = get_coordinates();
println!("Coordinates: [{}, {}]", coords[0], coords[1]);
println!("Website URL: {}", get_website_url());
println!("Facebook URL: {}", get_facebook_url());
println!("YouTube URL: {}", get_youtube_url());
println!("Instagram URL: {}", get_instagram_url());
println!("Mission Statement: {}", get_mission_statement());
println!("\n✅ All config functions working properly with fallback values!");
}

31
test_sermon_json.rs Normal file
View file

@ -0,0 +1,31 @@
use church_core::{ChurchApiClient, ChurchCoreConfig};
#[tokio::main]
async fn main() {
let config = ChurchCoreConfig::new();
match ChurchApiClient::new(config) {
Ok(client) => {
match client.get_recent_sermons(Some(5)).await {
Ok(sermons) => {
let client_sermons: Vec<church_core::ClientSermon> = sermons
.into_iter()
.map(church_core::ClientSermon::from)
.collect();
println!("🎬 Raw JSON output:");
match serde_json::to_string_pretty(&client_sermons) {
Ok(json) => println!("{}", json),
Err(e) => println!("❌ JSON serialization error: {}", e),
}
},
Err(e) => {
println!("❌ Failed to get sermons: {}", e);
}
}
}
Err(e) => {
println!("❌ Failed to create client: {}", e);
}
}
}

73
tests/calendar_test.rs Normal file
View file

@ -0,0 +1,73 @@
use church_core::create_calendar_event_data;
#[test]
fn test_calendar_event_parsing() {
// Test with various timestamp formats that might come from the iOS app
// Test 1: ISO 8601 with timezone
let event_with_tz = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"start_time": "2025-01-15T14:30:00-05:00",
"end_time": "2025-01-15T16:00:00-05:00"
}"#;
println!("🧪 Testing ISO 8601 with timezone:");
let result1 = create_calendar_event_data(event_with_tz.to_string());
println!("Result: {}", result1);
// Test 2: ISO 8601 without timezone
let event_no_tz = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"start_time": "2025-01-15T14:30:00",
"end_time": "2025-01-15T16:00:00"
}"#;
println!("\n🧪 Testing ISO 8601 without timezone:");
let result2 = create_calendar_event_data(event_no_tz.to_string());
println!("Result: {}", result2);
// Test 3: Date with space separator
let event_space = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"start_time": "2025-01-15 14:30:00",
"end_time": "2025-01-15 16:00:00"
}"#;
println!("\n🧪 Testing space-separated format:");
let result3 = create_calendar_event_data(event_space.to_string());
println!("Result: {}", result3);
// Test 4: camelCase field names (Swift encoding style)
let event_camel_case = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"startTime": "2025-01-15T14:30:00",
"endTime": "2025-01-15T16:00:00"
}"#;
println!("\n🧪 Testing camelCase field names:");
let result4 = create_calendar_event_data(event_camel_case.to_string());
println!("Result: {}", result4);
// Test 5: What might actually come from iOS encoding of ChurchEvent
let event_ios_style = r#"{
"id": "test123",
"title": "Test Event",
"description": "Test Description",
"startTime": "2025-01-15T14:30:00Z",
"endTime": "2025-01-15T16:00:00Z",
"location": "Test Location",
"category": "general"
}"#;
println!("\n🧪 Testing iOS-style encoding:");
let result5 = create_calendar_event_data(event_ios_style.to_string());
println!("Result: {}", result5);
}

View file

@ -0,0 +1,71 @@
use church_core::uniffi_wrapper::{
get_church_name,
get_contact_phone,
get_contact_email,
get_brand_color,
get_about_text,
get_donation_url,
get_church_address,
get_coordinates,
get_website_url,
get_facebook_url,
get_youtube_url,
get_instagram_url,
get_mission_statement,
};
#[test]
fn test_config_functions_return_fallback_values() {
println!("🔍 Testing church-core config functions...");
// Test that all functions return non-empty strings (fallback values)
let church_name = get_church_name();
assert!(!church_name.is_empty(), "Church name should have fallback");
println!("✅ Church Name: {}", church_name);
let contact_phone = get_contact_phone();
assert!(!contact_phone.is_empty(), "Contact phone should have fallback");
println!("✅ Contact Phone: {}", contact_phone);
let contact_email = get_contact_email();
assert!(!contact_email.is_empty(), "Contact email should have fallback");
println!("✅ Contact Email: {}", contact_email);
let brand_color = get_brand_color();
assert!(!brand_color.is_empty(), "Brand color should have fallback");
assert!(brand_color.starts_with("#"), "Brand color should be hex");
println!("✅ Brand Color: {}", brand_color);
let about_text = get_about_text();
assert!(!about_text.is_empty(), "About text should have fallback");
println!("✅ About Text: {}", about_text);
let donation_url = get_donation_url();
assert!(!donation_url.is_empty(), "Donation URL should have fallback");
assert!(donation_url.starts_with("http"), "Donation URL should be valid");
println!("✅ Donation URL: {}", donation_url);
let church_address = get_church_address();
assert!(!church_address.is_empty(), "Church address should have fallback");
println!("✅ Church Address: {}", church_address);
let coords = get_coordinates();
// Coordinates should be empty when no coordinates are configured
assert_eq!(coords.len(), 0, "Coordinates should be empty when not configured");
println!("✅ Coordinates: {:?} (empty when not configured)", coords);
let website_url = get_website_url();
assert!(!website_url.is_empty(), "Website URL should have fallback");
println!("✅ Website URL: {}", website_url);
let mission_statement = get_mission_statement();
assert!(!mission_statement.is_empty(), "Mission statement should have fallback");
println!("✅ Mission Statement: {}", mission_statement);
// Social media URLs can be empty
let _facebook_url = get_facebook_url();
let _youtube_url = get_youtube_url();
let _instagram_url = get_instagram_url();
println!("🎉 All config functions working properly!");
}

View file

@ -0,0 +1,208 @@
use church_core::{
ChurchApiClient, ChurchCoreConfig,
error::ChurchApiError,
};
use mockito::{self, mock};
use serde_json::json;
use std::time::Duration;
#[cfg(test)]
mod error_handling_tests {
use super::*;
fn create_test_client() -> ChurchApiClient {
let config = ChurchCoreConfig::new()
.with_base_url(&mockito::server_url())
.with_timeout(Duration::from_millis(100))
.with_retry_attempts(1)
.with_offline_mode(false);
ChurchApiClient::new(config).unwrap()
}
#[tokio::test]
async fn test_http_404_error() {
let _m = mock("GET", "/events/nonexistent")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"error": "Not found"
}).to_string())
.create();
let client = create_test_client();
let result = client.get_event("nonexistent").await;
// For get_event, 404 should return None, not an error
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn test_http_401_unauthorized() {
let _m = mock("POST", "/events")
.with_status(401)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"error": "Unauthorized access"
}).to_string())
.create();
let client = create_test_client();
let new_event = church_core::models::NewEvent {
title: "Protected Event".to_string(),
description: "Requires authentication".to_string(),
start_time: chrono::Utc::now(),
end_time: chrono::Utc::now() + chrono::Duration::hours(1),
location: "Secure Location".to_string(),
location_url: None,
image: None,
category: church_core::models::EventCategory::Other,
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
};
let result = client.create_event(new_event).await;
assert!(result.is_err());
if let Err(error) = result {
assert!(matches!(error, ChurchApiError::Auth(_)));
assert!(error.is_auth_error());
}
}
#[tokio::test]
async fn test_http_403_forbidden() {
let _m = mock("PUT", "/config")
.with_status(403)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"error": "Forbidden - insufficient permissions"
}).to_string())
.create();
let client = create_test_client();
let config = church_core::models::ChurchConfig {
church_name: Some("Test Church".to_string()),
church_address: None,
contact_phone: None,
contact_email: None,
website_url: None,
google_maps_url: None,
facebook_url: None,
youtube_url: None,
instagram_url: None,
about_text: None,
mission_statement: None,
service_times: None,
pastoral_staff: None,
ministries: None,
app_settings: None,
emergency_contacts: None,
};
let result = client.update_config(config).await;
assert!(result.is_err());
if let Err(error) = result {
assert!(matches!(error, ChurchApiError::PermissionDenied));
assert!(error.is_auth_error());
}
}
#[tokio::test]
async fn test_http_500_server_error() {
let _m = mock("GET", "/events/upcoming")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"error": "Internal server error"
}).to_string())
.create();
let client = create_test_client();
let result = client.get_upcoming_events(None).await;
assert!(result.is_err());
if let Err(error) = result {
assert!(matches!(error, ChurchApiError::Http(_)));
assert!(error.is_network_error());
}
}
#[tokio::test]
async fn test_invalid_json_response() {
let _m = mock("GET", "/events/upcoming")
.with_status(200)
.with_header("content-type", "application/json")
.with_body("invalid json {")
.create();
let client = create_test_client();
let result = client.get_upcoming_events(None).await;
assert!(result.is_err());
// Invalid JSON will likely cause an HTTP parsing error instead of JSON error
// since the response can't be properly parsed as JSON
if let Err(error) = result {
// Could be either JSON or HTTP error depending on how reqwest handles it
assert!(matches!(error, ChurchApiError::Json(_)) || matches!(error, ChurchApiError::Http(_)));
}
}
#[tokio::test]
async fn test_api_error_response() {
let _m = mock("GET", "/events/upcoming")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"data": null,
"error": "Custom API error message"
}).to_string())
.create();
let client = create_test_client();
let result = client.get_upcoming_events(None).await;
assert!(result.is_err());
if let Err(error) = result {
assert!(matches!(error, ChurchApiError::Api(_)));
if let ChurchApiError::Api(message) = error {
assert_eq!(message, "Custom API error message");
}
}
}
#[test]
fn test_error_classification() {
let auth_error = ChurchApiError::Auth("Invalid token".to_string());
assert!(!auth_error.is_network_error());
assert!(!auth_error.is_temporary());
assert!(auth_error.is_auth_error());
let rate_limit_error = ChurchApiError::RateLimit;
assert!(!rate_limit_error.is_network_error());
assert!(rate_limit_error.is_temporary());
assert!(!rate_limit_error.is_auth_error());
let permission_error = ChurchApiError::PermissionDenied;
assert!(!permission_error.is_network_error());
assert!(!permission_error.is_temporary());
assert!(permission_error.is_auth_error());
let not_found_error = ChurchApiError::NotFound;
assert!(!not_found_error.is_network_error());
assert!(!not_found_error.is_temporary());
assert!(!not_found_error.is_auth_error());
}
}

367
tests/integration_tests.rs Normal file
View file

@ -0,0 +1,367 @@
use church_core::{
ChurchApiClient, ChurchCoreConfig,
models::*,
error::ChurchApiError,
};
use mockito::{self, mock};
use serde_json::json;
use std::time::Duration;
use tokio_test;
#[cfg(test)]
mod integration_tests {
use super::*;
fn create_test_client() -> ChurchApiClient {
let config = ChurchCoreConfig::new()
.with_base_url(&mockito::server_url())
.with_timeout(Duration::from_secs(5))
.with_retry_attempts(1)
.with_offline_mode(false);
ChurchApiClient::new(config).unwrap()
}
#[tokio::test]
async fn test_get_upcoming_events_success() {
let _m = mock("GET", "/events/upcoming")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({
"success": true,
"data": [{
"id": "event-1",
"title": "Sunday Service",
"description": "Weekly worship service",
"start_time": "2024-07-14T11:00:00Z",
"end_time": "2024-07-14T12:30:00Z",
"location": "Main Sanctuary",
"location_url": null,
"image": null,
"thumbnail": null,
"category": "service",
"is_featured": true,
"recurring_type": "weekly",
"tags": ["worship", "sunday"],
"contact_email": null,
"contact_phone": null,
"registration_url": null,
"max_attendees": null,
"current_attendees": null,
"created_at": "2024-07-01T10:00:00Z",
"updated_at": "2024-07-01T10:00:00Z"
}]
}).to_string())
.create();
let client = create_test_client();
let events = client.get_upcoming_events(None).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, "event-1");
assert_eq!(events[0].title, "Sunday Service");
assert_eq!(events[0].category, EventCategory::Service);
assert!(events[0].is_featured);
}
#[tokio::test]
async fn test_get_upcoming_events_with_limit() {
let _m = mock("GET", "/events/upcoming?limit=5")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({
"success": true,
"data": []
}).to_string())
.create();
let client = create_test_client();
let events = client.get_upcoming_events(Some(5)).await.unwrap();
assert_eq!(events.len(), 0);
}
#[tokio::test]
async fn test_get_event_success() {
let _m = mock("GET", "/events/event-123")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({
"success": true,
"data": {
"id": "event-123",
"title": "Bible Study",
"description": "Weekly Bible study group",
"start_time": "2024-07-15T19:00:00Z",
"end_time": "2024-07-15T21:00:00Z",
"location": "Fellowship Hall",
"location_url": null,
"image": null,
"thumbnail": null,
"category": "education",
"is_featured": false,
"recurring_type": null,
"tags": ["bible", "study"],
"contact_email": "study@church.org",
"contact_phone": null,
"registration_url": null,
"max_attendees": 20,
"current_attendees": 12,
"created_at": "2024-07-01T10:00:00Z",
"updated_at": "2024-07-01T10:00:00Z"
}
}).to_string())
.create();
let client = create_test_client();
let event = client.get_event("event-123").await.unwrap();
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.id, "event-123");
assert_eq!(event.title, "Bible Study");
assert_eq!(event.category, EventCategory::Education);
assert_eq!(event.spots_remaining(), Some(8));
}
#[tokio::test]
async fn test_get_event_not_found() {
let _m = mock("GET", "/events/nonexistent")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"error": "Event not found"
}).to_string())
.create();
let client = create_test_client();
let event = client.get_event("nonexistent").await.unwrap();
assert!(event.is_none());
}
#[tokio::test]
async fn test_create_event_success() {
let _m = mock("POST", "/events")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(json!({
"success": true,
"data": "new-event-id"
}).to_string())
.create();
let client = create_test_client();
let new_event = NewEvent {
title: "New Event".to_string(),
description: "A new event".to_string(),
start_time: chrono::Utc::now(),
end_time: chrono::Utc::now() + chrono::Duration::hours(2),
location: "Main Hall".to_string(),
location_url: None,
image: None,
category: EventCategory::Ministry,
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
};
let event_id = client.create_event(new_event).await.unwrap();
assert_eq!(event_id, "new-event-id");
}
#[tokio::test]
async fn test_get_current_bulletin_success() {
let _m = mock("GET", "/bulletins/current")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({
"success": true,
"data": {
"id": "bulletin-current",
"title": "This Week's Bulletin",
"date": "2024-07-13",
"sabbath_school": "9:30 AM",
"divine_worship": "11:00 AM",
"scripture_reading": "Psalm 23",
"sunset": "7:45 PM",
"pdf_path": "/bulletins/current.pdf",
"cover_image": "/images/cover.jpg",
"is_active": true,
"announcements": [{
"id": "ann-1",
"title": "Potluck Next Week",
"content": "Join us for fellowship after service",
"category": "social",
"is_urgent": false,
"contact_info": null,
"expires_at": "2025-12-31T00:00:00Z"
}],
"hymns": [{
"number": 1,
"title": "Holy, Holy, Holy",
"category": "opening",
"verses": [1, 2, 3]
}],
"special_music": null,
"offering_type": "Local Church Budget",
"sermon_title": "Walking by Faith",
"speaker": "Pastor Smith",
"liturgy": null,
"created_at": "2024-07-01T10:00:00Z",
"updated_at": "2024-07-13T08:00:00Z"
}
}).to_string())
.create();
let client = create_test_client();
let bulletin = client.get_current_bulletin().await.unwrap();
assert!(bulletin.is_some());
let bulletin = bulletin.unwrap();
assert_eq!(bulletin.id, "bulletin-current");
assert_eq!(bulletin.title, "This Week's Bulletin");
assert!(bulletin.has_pdf());
assert!(bulletin.has_cover_image());
assert_eq!(bulletin.active_announcements().len(), 1);
}
#[tokio::test]
async fn test_get_current_bulletin_not_found() {
let _m = mock("GET", "/bulletins/current")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"error": "No current bulletin found"
}).to_string())
.create();
let client = create_test_client();
let bulletin = client.get_current_bulletin().await.unwrap();
assert!(bulletin.is_none());
}
#[tokio::test]
async fn test_get_config_success() {
let _m = mock("GET", "/config")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({
"success": true,
"data": {
"church_name": "Test Church",
"church_address": "123 Faith Street, Hometown, ST 12345",
"contact_phone": "555-123-4567",
"contact_email": "info@testchurch.org",
"website_url": "https://testchurch.org",
"google_maps_url": "https://maps.google.com/test",
"facebook_url": "https://facebook.com/testchurch",
"youtube_url": null,
"instagram_url": null,
"about_text": "A welcoming church community",
"mission_statement": "Spreading God's love",
"service_times": {
"sabbath_school": "9:30 AM",
"divine_worship": "11:00 AM",
"prayer_meeting": "7:00 PM Wednesday",
"youth_service": null,
"special_services": null
},
"pastoral_staff": [{
"name": "Pastor John Smith",
"title": "Senior Pastor",
"email": "pastor@testchurch.org",
"phone": "555-123-4568",
"photo": null,
"bio": "Leading our church with love",
"responsibilities": ["Preaching", "Pastoral Care"]
}],
"ministries": null,
"app_settings": {
"enable_notifications": true,
"enable_calendar_sync": true,
"enable_offline_mode": true,
"theme": "system",
"default_language": "en",
"jellyfin_server": null,
"owncast_server": null,
"bible_version": "KJV",
"hymnal_version": "1985",
"cache_duration_minutes": 60,
"auto_refresh_interval_minutes": 15
},
"emergency_contacts": null
}
}).to_string())
.create();
let client = create_test_client();
let config = client.get_config().await.unwrap();
assert_eq!(config.church_name, Some("Test Church".to_string()));
assert_eq!(config.contact_phone, Some("555-123-4567".to_string()));
assert_eq!(config.get_display_name(), "Test Church");
assert!(config.has_social_media());
assert_eq!(config.get_contact_info().len(), 3); // phone, email, address
}
#[tokio::test]
async fn test_submit_contact_form_success() {
let _m = mock("POST", "/contact")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(json!({
"success": true,
"data": "contact-submission-123"
}).to_string())
.create();
let client = create_test_client();
let contact_form = ContactForm::new(
"Jane Doe".to_string(),
"jane@example.com".to_string(),
"Question about baptism".to_string(),
"I'm interested in learning about baptism.".to_string(),
).with_category(ContactCategory::Baptism);
let submission_id = client.submit_contact_form(contact_form).await.unwrap();
assert_eq!(submission_id, "contact-submission-123");
}
#[tokio::test]
async fn test_api_error_handling() {
let _m = mock("GET", "/events/upcoming")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(json!({
"success": false,
"error": "Internal server error"
}).to_string())
.create();
let client = create_test_client();
let result = client.get_upcoming_events(None).await;
assert!(result.is_err());
if let Err(error) = result {
assert!(matches!(error, ChurchApiError::Http(_)));
}
}
#[tokio::test]
async fn test_health_check() {
let _m = mock("GET", "/health")
.with_status(200)
.with_header("content-type", "application/json")
.with_body("OK")
.create();
let client = create_test_client();
let is_healthy = client.health_check().await.unwrap();
assert!(is_healthy);
}
}

View file

@ -0,0 +1,81 @@
#[cfg(test)]
mod tests {
use church_core::create_calendar_event_data;
#[test]
fn test_calendar_event_parsing() {
// Test with various timestamp formats that might come from the iOS app
// Test 1: ISO 8601 with timezone
let event_with_tz = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"start_time": "2025-01-15T14:30:00-05:00",
"end_time": "2025-01-15T16:00:00-05:00"
}"#;
println!("🧪 Testing ISO 8601 with timezone:");
let result1 = create_calendar_event_data(event_with_tz.to_string());
println!("Result: {}", result1);
// Test 2: ISO 8601 without timezone
let event_no_tz = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"start_time": "2025-01-15T14:30:00",
"end_time": "2025-01-15T16:00:00"
}"#;
println!("\n🧪 Testing ISO 8601 without timezone:");
let result2 = create_calendar_event_data(event_no_tz.to_string());
println!("Result: {}", result2);
// Test 3: Date with space separator
let event_space = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"start_time": "2025-01-15 14:30:00",
"end_time": "2025-01-15 16:00:00"
}"#;
println!("\n🧪 Testing space-separated format:");
let result3 = create_calendar_event_data(event_space.to_string());
println!("Result: {}", result3);
// Test 4: camelCase field names (Swift encoding style)
let event_camel_case = r#"{
"title": "Test Event",
"description": "Test Description",
"location": "Test Location",
"startTime": "2025-01-15T14:30:00",
"endTime": "2025-01-15T16:00:00"
}"#;
println!("\n🧪 Testing camelCase field names:");
let result4 = create_calendar_event_data(event_camel_case.to_string());
println!("Result: {}", result4);
// Test 5: What might actually come from iOS encoding of ChurchEvent
let event_ios_style = r#"{
"id": "test123",
"title": "Test Event",
"description": "Test Description",
"startTime": "2025-01-15T14:30:00Z",
"endTime": "2025-01-15T16:00:00Z",
"location": "Test Location",
"category": "general"
}"#;
println!("\n🧪 Testing iOS-style encoding:");
let result5 = create_calendar_event_data(event_ios_style.to_string());
println!("Result: {}", result5);
// Check that at least one format works
assert!(result1.contains("\"success\": true") || result2.contains("\"success\": true") ||
result3.contains("\"success\": true") || result4.contains("\"success\": true") ||
result5.contains("\"success\": true"), "At least one timestamp format should work");
}
}

489
tests/unit_tests.rs Normal file
View file

@ -0,0 +1,489 @@
use church_core::{
models::*,
cache::MemoryCache,
ChurchCoreConfig,
ChurchApiClient,
};
use chrono::{DateTime, Utc, NaiveDate};
use std::time::Duration;
use tokio_test;
#[cfg(test)]
mod model_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_event_creation() {
let event = Event {
id: "test-id".to_string(),
title: "Bible Study".to_string(),
description: "Weekly study".to_string(),
start_time: Utc::now(),
end_time: Utc::now() + chrono::Duration::hours(2),
location: "Fellowship Hall".to_string(),
location_url: Some("https://maps.google.com".to_string()),
image: None,
thumbnail: None,
category: EventCategory::Education,
is_featured: true,
recurring_type: Some(RecurringType::Weekly),
tags: Some(vec!["bible".to_string(), "study".to_string()]),
contact_email: Some("contact@church.org".to_string()),
contact_phone: None,
registration_url: None,
max_attendees: Some(30),
current_attendees: Some(15),
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert_eq!(event.title, "Bible Study");
assert_eq!(event.category, EventCategory::Education);
assert!(event.is_featured);
assert_eq!(event.spots_remaining(), Some(15));
assert!(!event.is_full());
}
#[test]
fn test_event_time_methods() {
let now = Utc::now();
let past_time = now - chrono::Duration::hours(1);
let future_time = now + chrono::Duration::hours(1);
// Test upcoming event
let upcoming_event = Event {
id: "upcoming".to_string(),
title: "Future Event".to_string(),
description: "".to_string(),
start_time: future_time,
end_time: future_time + chrono::Duration::hours(1),
location: "".to_string(),
location_url: None,
image: None,
thumbnail: None,
category: EventCategory::Service,
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
current_attendees: None,
created_at: now,
updated_at: now,
};
// Test duration calculation
assert_eq!(upcoming_event.duration_minutes(), 60);
// Test past event
let past_event = Event {
id: "past".to_string(),
title: "Past Event".to_string(),
description: "".to_string(),
start_time: past_time - chrono::Duration::hours(1),
end_time: past_time,
location: "".to_string(),
location_url: None,
image: None,
thumbnail: None,
category: EventCategory::Service,
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
current_attendees: None,
created_at: past_time,
updated_at: past_time,
};
// Test duration calculation for past event
assert_eq!(past_event.duration_minutes(), 60);
}
#[test]
fn test_bulletin_creation() {
let bulletin = Bulletin {
id: "bulletin-1".to_string(),
title: "Weekly Bulletin".to_string(),
date: NaiveDate::from_ymd_opt(2024, 1, 6).unwrap(),
sabbath_school: "9:30 AM".to_string(),
divine_worship: "11:00 AM".to_string(),
scripture_reading: "John 3:16".to_string(),
sunset: "5:45 PM".to_string(),
pdf_path: Some("/bulletins/2024-01-06.pdf".to_string()),
cover_image: Some("/images/cover.jpg".to_string()),
is_active: true,
announcements: Some(vec![
Announcement {
id: Some("ann-1".to_string()),
title: "Potluck Dinner".to_string(),
content: "Join us for fellowship".to_string(),
category: Some(AnnouncementCategory::Social),
is_urgent: false,
contact_info: None,
expires_at: Some(Utc::now() + chrono::Duration::days(7)),
}
]),
hymns: Some(vec![
BulletinHymn {
number: 15,
title: "Amazing Grace".to_string(),
category: Some(HymnCategory::Opening),
verses: Some(vec![1, 2, 4]),
}
]),
special_music: Some("Choir Special".to_string()),
offering_type: Some("Local Church Budget".to_string()),
sermon_title: Some("Walking in Faith".to_string()),
speaker: Some("Pastor Smith".to_string()),
liturgy: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert_eq!(bulletin.title, "Weekly Bulletin");
assert!(bulletin.has_pdf());
assert!(bulletin.has_cover_image());
assert_eq!(bulletin.active_announcements().len(), 1);
assert_eq!(bulletin.urgent_announcements().len(), 0);
}
#[test]
fn test_contact_form_creation() {
let contact = ContactForm::new(
"John Doe".to_string(),
"john@example.com".to_string(),
"Prayer Request".to_string(),
"Please pray for my family.".to_string(),
)
.with_category(ContactCategory::PrayerRequest)
.with_phone("555-123-4567".to_string())
.mark_urgent();
assert_eq!(contact.name, "John Doe");
assert_eq!(contact.email, "john@example.com");
assert!(contact.is_urgent());
assert!(!contact.is_visitor());
assert_eq!(contact.category, Some(ContactCategory::PrayerRequest));
}
#[test]
fn test_sermon_search() {
let sermon = Sermon {
id: "sermon-1".to_string(),
title: "The Power of Prayer".to_string(),
speaker: "Pastor Johnson".to_string(),
description: "Learning to pray effectively".to_string(),
date: Utc::now() - chrono::Duration::days(7),
scripture_reference: "Matthew 6:9-13".to_string(),
series: Some("Prayer Series".to_string()),
duration_seconds: Some(2400), // 40 minutes
media_url: Some("https://media.church.org/sermon1.mp4".to_string()),
audio_url: Some("https://media.church.org/sermon1.mp3".to_string()),
video_url: Some("https://video.church.org/sermon1.mp4".to_string()),
transcript: Some("Today we explore...".to_string()),
thumbnail: Some("https://media.church.org/thumb1.jpg".to_string()),
tags: Some(vec!["prayer".to_string(), "spiritual".to_string()]),
category: SermonCategory::Regular,
is_featured: true,
view_count: 150,
download_count: 45,
created_at: Utc::now() - chrono::Duration::days(8),
updated_at: Utc::now() - chrono::Duration::days(7),
};
assert_eq!(sermon.duration_formatted(), "40m");
assert!(sermon.has_media());
assert!(sermon.has_audio());
assert!(sermon.has_video());
assert!(sermon.has_transcript());
assert!(sermon.is_popular());
// Test search matching
let search = SermonSearch::new()
.with_query("prayer".to_string())
.with_speaker("Johnson".to_string())
.featured_only();
assert!(sermon.matches_search(&search));
let non_matching_search = SermonSearch::new()
.with_query("baptism".to_string());
assert!(!sermon.matches_search(&non_matching_search));
}
#[test]
fn test_auth_token() {
let token = AuthToken {
token: "abc123".to_string(),
token_type: "Bearer".to_string(),
expires_at: Utc::now() + chrono::Duration::hours(1),
user_id: Some("user-1".to_string()),
user_name: Some("John Doe".to_string()),
user_email: Some("john@church.org".to_string()),
permissions: vec!["events.read".to_string(), "bulletins.read".to_string()],
};
assert!(token.is_valid());
assert!(!token.is_expired());
assert!(token.has_permission("events.read"));
assert!(!token.has_permission("admin.write"));
assert!(token.has_any_permission(&["events.read", "admin.write"]));
assert!(!token.has_all_permissions(&["events.read", "admin.write"]));
}
#[test]
fn test_user_roles() {
let admin_user = AuthUser {
id: "admin-1".to_string(),
email: "admin@church.org".to_string(),
name: "Admin User".to_string(),
username: Some("admin".to_string()),
avatar: None,
verified: true,
role: UserRole::Admin,
permissions: vec!["admin.*".to_string()],
created_at: Utc::now(),
updated_at: Utc::now(),
last_login: Some(Utc::now()),
};
assert!(admin_user.is_admin());
assert!(admin_user.is_leadership());
assert!(admin_user.can_edit_content());
assert!(admin_user.can_moderate());
assert_eq!(admin_user.display_name(), "Admin User");
let member_user = AuthUser {
id: "member-1".to_string(),
email: "member@church.org".to_string(),
name: "".to_string(),
username: Some("member123".to_string()),
avatar: None,
verified: true,
role: UserRole::Member,
permissions: vec!["events.read".to_string()],
created_at: Utc::now(),
updated_at: Utc::now(),
last_login: Some(Utc::now()),
};
assert!(!member_user.is_admin());
assert!(!member_user.is_leadership());
assert!(!member_user.can_edit_content());
assert!(!member_user.can_moderate());
assert!(member_user.is_member());
assert_eq!(member_user.display_name(), "member123");
}
#[test]
fn test_api_response_models() {
let response: ApiResponse<String> = ApiResponse {
success: true,
data: Some("test data".to_string()),
message: Some("Success".to_string()),
error: None,
};
assert!(response.success);
assert_eq!(response.data, Some("test data".to_string()));
let error_response: ApiResponse<String> = ApiResponse {
success: false,
data: None,
message: None,
error: Some("Not found".to_string()),
};
assert!(!error_response.success);
assert_eq!(error_response.data, None);
}
#[test]
fn test_pagination_params() {
let params = PaginationParams::new()
.with_page(2)
.with_per_page(25)
.with_sort("created_at".to_string())
.with_filter("active=true".to_string());
assert_eq!(params.page, Some(2));
assert_eq!(params.per_page, Some(25));
assert_eq!(params.sort, Some("created_at".to_string()));
assert_eq!(params.filter, Some("active=true".to_string()));
}
}
#[cfg(test)]
mod cache_tests {
use super::*;
use tokio_test;
#[tokio::test]
async fn test_memory_cache_basic_operations() {
let cache = MemoryCache::new(100);
let ttl = Duration::from_secs(60);
// Test set and get
cache.set("key1", &"value1", ttl).await;
let result: Option<String> = cache.get("key1").await;
assert_eq!(result, Some("value1".to_string()));
// Test get non-existent key
let result: Option<String> = cache.get("nonexistent").await;
assert_eq!(result, None);
// Test remove
cache.remove("key1").await;
let result: Option<String> = cache.get("key1").await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_memory_cache_ttl_expiration() {
let cache = MemoryCache::new(100);
let short_ttl = Duration::from_millis(50);
// Set value with short TTL
cache.set("expire_key", &"expire_value", short_ttl).await;
// Should be available immediately
let result: Option<String> = cache.get("expire_key").await;
assert_eq!(result, Some("expire_value".to_string()));
// Wait for expiration
tokio::time::sleep(Duration::from_millis(100)).await;
// Should be expired
let result: Option<String> = cache.get("expire_key").await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_memory_cache_clear() {
let cache = MemoryCache::new(100);
let ttl = Duration::from_secs(60);
// Set multiple values
cache.set("key1", &"value1", ttl).await;
cache.set("key2", &"value2", ttl).await;
cache.set("key3", &"value3", ttl).await;
assert_eq!(cache.len().await, 3);
// Clear cache
cache.clear().await;
assert_eq!(cache.len().await, 0);
// Verify values are gone
let result: Option<String> = cache.get("key1").await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_memory_cache_invalidate_prefix() {
let cache = MemoryCache::new(100);
let ttl = Duration::from_secs(60);
// Set values with different prefixes
cache.set("user:1", &"user1", ttl).await;
cache.set("user:2", &"user2", ttl).await;
cache.set("event:1", &"event1", ttl).await;
cache.set("event:2", &"event2", ttl).await;
assert_eq!(cache.len().await, 4);
// Invalidate user prefix
cache.invalidate_prefix("user:").await;
assert_eq!(cache.len().await, 2);
// Verify user keys are gone but event keys remain
let result: Option<String> = cache.get("user:1").await;
assert_eq!(result, None);
let result: Option<String> = cache.get("event:1").await;
assert_eq!(result, Some("event1".to_string()));
}
#[tokio::test]
async fn test_memory_cache_complex_types() {
let cache = MemoryCache::new(100);
let ttl = Duration::from_secs(60);
let event = Event {
id: "test-event".to_string(),
title: "Test Event".to_string(),
description: "A test event".to_string(),
start_time: Utc::now(),
end_time: Utc::now() + chrono::Duration::hours(1),
location: "Test Location".to_string(),
location_url: None,
image: None,
thumbnail: None,
category: EventCategory::Service,
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
current_attendees: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Cache complex object
cache.set("event:test", &event, ttl).await;
// Retrieve and verify
let result: Option<Event> = cache.get("event:test").await;
assert!(result.is_some());
let cached_event = result.unwrap();
assert_eq!(cached_event.id, "test-event");
assert_eq!(cached_event.title, "Test Event");
assert_eq!(cached_event.category, EventCategory::Service);
}
}
#[cfg(test)]
mod config_tests {
use super::*;
#[test]
fn test_church_core_config() {
let config = ChurchCoreConfig::new()
.with_base_url("https://api.test.church")
.with_cache_ttl(Duration::from_secs(600))
.with_timeout(Duration::from_secs(15))
.with_retry_attempts(5)
.with_offline_mode(false)
.with_max_cache_size(2000);
assert_eq!(config.api_base_url, "https://api.test.church");
assert_eq!(config.cache_ttl, Duration::from_secs(600));
assert_eq!(config.timeout, Duration::from_secs(15));
assert_eq!(config.retry_attempts, 5);
assert!(!config.enable_offline_mode);
assert_eq!(config.max_cache_size, 2000);
}
#[test]
fn test_default_config() {
let config = ChurchCoreConfig::default();
assert_eq!(config.api_base_url, "https://api.rockvilletollandsda.church/api");
assert_eq!(config.cache_ttl, Duration::from_secs(300));
assert_eq!(config.timeout, Duration::from_secs(10));
assert_eq!(config.retry_attempts, 3);
assert!(config.enable_offline_mode);
assert_eq!(config.max_cache_size, 1000);
}
}