Restructure project and update gitignore

- Flatten directory structure by moving files from astro-church-website/ to root
- Remove church-core subdirectory in favor of inline Rust library
- Update .gitignore to properly exclude build artifacts, generated files, and dependencies
- Add environment variables, IDE files, and coverage reports to gitignore
- Include generated CSS files and native bindings in ignore patterns
This commit is contained in:
Benjamin Slingo 2025-08-30 08:59:27 -04:00
parent 1013ca0870
commit 91a1bb7a54
120 changed files with 363 additions and 14267 deletions

23
.gitignore vendored
View file

@ -7,11 +7,16 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
# Astro build output
dist/
.astro/
# Generated CSS from Tailwind builds
public/css/theme-light.css
public/css/theme-dark.css
# Compiled binaries and libraries
*.node
*.so
@ -31,10 +36,21 @@ RTSDA/
# Build outputs
build/
*.log
index.cjs
index.d.ts
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
@ -44,3 +60,10 @@ ehthumbs.db
# Temporary files
*.tmp
*.temp
# Cargo build metadata
.cargo/
# Coverage reports
coverage/
*.lcov

Binary file not shown.

View file

@ -0,0 +1,56 @@
# RTSDA Web Project Overview
## Project Purpose
This is a church website project for Rockville Tolland Seventh-day Adventist Church, featuring:
- Event management and submission system
- Sermon archive and streaming
- Bulletin management
- Contact forms
- Admin interface
## Tech Stack
- **Frontend**: Astro 5.13.0 with TailwindCSS
- **Backend**: Rust-based church-core library with NAPI-RS bindings
- **Architecture**: Hybrid - Astro frontend with Rust backend via native bindings
- **API**: External API at `https://api.rockvilletollandsda.church/api/`
## Key Components
- `astro-church-website/` - Main Astro frontend
- `church-core/` - Rust backend library
- `axum-church-website/` - Alternative backend (Axum-based)
## Project Structure
```
/
├── astro-church-website/ # Main Astro frontend
│ ├── src/
│ │ ├── components/ # Reusable components
│ │ ├── layouts/ # Page layouts
│ │ ├── pages/ # Astro pages and API routes
│ │ │ ├── api/ # API endpoints
│ │ │ ├── events/ # Event-related pages
│ │ │ ├── admin/ # Admin interface
│ │ │ └── bulletin/ # Bulletin management
│ │ └── lib/ # Utilities and bindings
│ ├── package.json
│ ├── Cargo.toml # Rust dependencies for NAPI
│ └── build.rs # Build script
├── church-core/ # Rust backend library
└── axum-church-website/ # Alternative Axum backend
```
## Development Commands
- `npm run dev` - Start development server (localhost:4321)
- `npm run build` - Build production site (includes native build)
- `npm run build:native` - Build native Rust bindings only
- `npm run preview` - Preview built site
## Key Features
1. **Event Submission Form** (`/events/submit`) - Users can submit events for approval
2. **Admin Interface** (`/admin`) - Event management and approval
3. **Bulletin Archive** (`/bulletin`) - Historical bulletins
4. **Live Streaming** (`/live`) - Church service streaming
5. **Sermon Archive** (`/sermons`) - Past sermons and audio
## Known Issue
There's a database error when users select "2nd 3rd Saturday" in the recurring field of the event submission form, causing a 500 error.

View file

@ -0,0 +1,71 @@
# Suggested Development Commands
## Core Development Commands
```bash
# Install dependencies
npm install
# Start development server
npm run dev # Starts at localhost:4321
# Build for production
npm run build # Includes native Rust build
npm run build:native # Build only Rust bindings
npm run preview # Preview production build
# Astro CLI commands
npm run astro ... # Run any Astro CLI command
npm run astro -- --help # Get Astro CLI help
```
## System Utilities (Darwin/macOS)
```bash
# Basic file operations
ls # List files
cd <directory> # Change directory
find . -name "*.ts" # Find TypeScript files
grep -r "pattern" . # Search for patterns
# Git operations
git status # Check git status
git add . # Stage all changes
git commit -m "message" # Commit changes
git push # Push to remote
# Package management
npm install <package> # Install new package
npm uninstall <package> # Remove package
npm audit # Check for vulnerabilities
```
## Rust/NAPI Commands
```bash
# Native bindings
cargo build --release # Build Rust code
napi build --platform --release # Build NAPI bindings
# Rust development
cargo check # Check Rust code
cargo test # Run Rust tests
cargo clippy # Rust linter
```
## Testing & Quality (when available)
```bash
# Check if these exist in project:
npm run test # Run tests (if configured)
npm run lint # Lint code (if configured)
npm run format # Format code (if configured)
npm run typecheck # TypeScript checking (if configured)
```
## Debugging
```bash
# Development debugging
npm run dev -- --verbose # Verbose development mode
npm run build -- --verbose # Verbose build
# View logs
tail -f logs/app.log # If logs exist
console logs via browser dev tools # For frontend debugging
```

View file

@ -0,0 +1,52 @@
# Task Completion Checklist
When completing any development task in this project, follow these steps:
## 1. Code Quality Checks
- [ ] **TypeScript Validation**: Run `npx tsc --noEmit` to check TypeScript types
- [ ] **Build Test**: Run `npm run build` to ensure the project builds successfully
- [ ] **Development Server**: Test with `npm run dev` to ensure functionality works locally
## 2. Rust/Native Code (if applicable)
- [ ] **Rust Check**: Run `cargo check` in the church-core directory
- [ ] **Native Build**: Run `npm run build:native` to ensure NAPI bindings compile
- [ ] **Cargo Clippy**: Run `cargo clippy` for Rust linting (if available)
## 3. Frontend Testing
- [ ] **Manual Testing**: Test all affected functionality in the browser
- [ ] **Cross-browser Check**: Test in different browsers if UI changes were made
- [ ] **Mobile Responsiveness**: Check mobile layout if UI changes were made
## 4. API Integration
- [ ] **API Endpoints**: Test any modified API routes (`/api/*`)
- [ ] **External API**: Verify integration with `https://api.rockvilletollandsda.church/api/`
- [ ] **Error Handling**: Ensure proper error responses and user feedback
## 5. Documentation
- [ ] **Code Comments**: Add comments for complex logic
- [ ] **README Updates**: Update documentation if new features or requirements
- [ ] **Memory Updates**: Update project memory files if architecture changes
## 6. Git Best Practices
- [ ] **Commit Messages**: Use clear, descriptive commit messages
- [ ] **Branch Management**: Work on feature branches when appropriate
- [ ] **Clean History**: Squash commits if multiple small fixes
## 7. Performance & Security
- [ ] **Bundle Size**: Check if changes affect build size significantly
- [ ] **Security**: Ensure no sensitive data is exposed
- [ ] **Caching**: Verify API caching headers are appropriate
## Common Issues to Watch For
- **NAPI Bindings**: Ensure native code compiles on target platforms
- **External API Dependency**: Handle cases where external API is unavailable
- **Image Uploads**: Validate file sizes and types properly
- **Form Validation**: Both client-side and server-side validation
- **Recurring Events**: Special attention to date/time handling
## Pre-deployment Checklist
- [ ] All TypeScript errors resolved
- [ ] Production build succeeds
- [ ] All features tested manually
- [ ] External dependencies verified
- [ ] Environment variables documented (if new ones added)

68
.serena/project.yml Normal file
View file

@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: rust
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed)on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file or directory.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "rtsda-web-main"

View file

@ -9,7 +9,7 @@ crate-type = ["cdylib"]
[dependencies]
napi = { version = "2", default-features = false, features = ["napi4"] }
napi-derive = "2"
church-core = { path = "../church-core" }
church-core = { git = "https://git.rockvilletollandsda.church/RTSDA/church-core.git" }
tokio = { version = "1", features = ["full"] }
[build-dependencies]

119
README.md
View file

@ -1,104 +1,43 @@
# RTSDA Website
# Astro Starter Kit: Minimal
The official website for Rockville Tolland Seventh-day Adventist Church, built with Astro and powered by Rust bindings.
## Features
- **Modern Astro Framework** - Fast, component-based architecture
- **Event Management** - Submit and manage church events with recurring types
- **Live Streaming** - Watch services live with HLS.js support
- **Mobile App Integration** - iOS and Android app download links
- **Admin Panel** - Manage events, bulletins, and church content
- **Three Angels' Message** - Dedicated sections for Adventist theology
- **Rust-Powered Backend** - High-performance API bindings via `church-core`
## Architecture
```
astro-church-website/ # Frontend Astro application
├── src/
│ ├── pages/ # Astro pages and API routes
│ ├── components/ # Reusable UI components
│ └── layouts/ # Page layouts
└── public/ # Static assets
church-core/ # Rust library for API bindings
├── src/
│ ├── client/ # API client implementations
│ ├── models/ # Data structures
│ └── uniffi_wrapper.rs # FFI bindings
└── Cargo.toml
```sh
npm create astro@latest -- --template minimal
```
## Development
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
### Prerequisites
## 🚀 Project Structure
- Node.js 18+ and npm
- Rust 1.70+
- Cargo
Inside of your Astro project, you'll see the following folders and files:
### Setup
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
1. **Install dependencies:**
```bash
cd astro-church-website
npm install
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
2. **Build Rust bindings:**
```bash
npm run build:native
```
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
3. **Start development server:**
```bash
npm run dev
```
Any static assets, like images, can be placed in the `public/` directory.
### Building for Production
## 🧞 Commands
1. **Build native bindings:**
```bash
npm run build:native
```
All commands are run from the root of the project, from a terminal:
2. **Build Astro site:**
```bash
npm run build
```
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
3. **Deploy to web server:**
```bash
cp -r dist/* /var/www/rtsda-website/
```
## 👀 Want to learn more?
## Recent Fixes
- **SecondThirdSaturday Recurring Type** - Added support for `"2nd/3rd Saturday Monthly"` events
- **Event Display Issues** - Fixed events page showing "No Events Scheduled"
- **iOS App Compatibility** - Resolved recurring type parsing errors
- **Mobile App Downloads** - Added iOS App Store and Android APK download buttons
- **Session Management** - Improved admin panel authentication handling
## API Integration
The site integrates with the church API at `https://api.rockvilletollandsda.church` for:
- Events and recurring schedules
- Sermon archives and live streams
- Church bulletins and announcements
- Contact form submissions
- Admin authentication
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
## License
See LICENSE file for details.
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View file

@ -1,8 +0,0 @@
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
[env]
CC_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-gcc"
CXX_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-g++"
AR_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-ar"
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER = "x86_64-linux-gnu-gcc"

View file

@ -1,24 +0,0 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

View file

@ -1,43 +0,0 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View file

@ -1,343 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/* prettier-ignore */
/* auto-generated by NAPI-RS */
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process').execSync('which ldd').toString().trim()
return readFileSync(lddPath, 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.android-arm64.node')
} else {
nativeBinding = require('astro-church-website-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.android-arm-eabi.node')
} else {
nativeBinding = require('astro-church-website-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-x64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-x64-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-ia32-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-ia32-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-arm64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-arm64-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.darwin-universal.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-universal.node')
} else {
nativeBinding = require('astro-church-website-darwin-universal')
}
break
} catch {}
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-x64.node')
} else {
nativeBinding = require('astro-church-website-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.darwin-arm64.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-arm64.node')
} else {
nativeBinding = require('astro-church-website-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.freebsd-x64.node')
} else {
nativeBinding = require('astro-church-website-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-x64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-x64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-x64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-x64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm-musleabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm-musleabihf.node')
} else {
nativeBinding = require('astro-church-website-linux-arm-musleabihf')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm-gnueabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('astro-church-website-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
}
break
case 'riscv64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-riscv64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-riscv64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-riscv64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-riscv64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-riscv64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 's390x':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-s390x-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-s390x-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-s390x-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const { getChurchName, fetchEventsJson, fetchFeaturedEventsJson, fetchSermonsJson, fetchConfigJson, getMissionStatement, fetchRandomBibleVerseJson, getStreamLiveStatus, getLivestreamUrl, getChurchAddress, getChurchPhysicalAddress, getChurchPoBox, getContactPhone, getContactEmail, getFacebookUrl, getYoutubeUrl, getInstagramUrl, submitContactV2Json, validateContactFormJson, fetchLivestreamArchiveJson, fetchBulletinsJson, fetchCurrentBulletinJson, fetchBibleVerseJson, submitEventJson, testAdminFunction, fetchAllSchedulesJson, createScheduleJson, updateScheduleJson, deleteScheduleJson } = nativeBinding
module.exports.getChurchName = getChurchName
module.exports.fetchEventsJson = fetchEventsJson
module.exports.fetchFeaturedEventsJson = fetchFeaturedEventsJson
module.exports.fetchSermonsJson = fetchSermonsJson
module.exports.fetchConfigJson = fetchConfigJson
module.exports.getMissionStatement = getMissionStatement
module.exports.fetchRandomBibleVerseJson = fetchRandomBibleVerseJson
module.exports.getStreamLiveStatus = getStreamLiveStatus
module.exports.getLivestreamUrl = getLivestreamUrl
module.exports.getChurchAddress = getChurchAddress
module.exports.getChurchPhysicalAddress = getChurchPhysicalAddress
module.exports.getChurchPoBox = getChurchPoBox
module.exports.getContactPhone = getContactPhone
module.exports.getContactEmail = getContactEmail
module.exports.getFacebookUrl = getFacebookUrl
module.exports.getYoutubeUrl = getYoutubeUrl
module.exports.getInstagramUrl = getInstagramUrl
module.exports.submitContactV2Json = submitContactV2Json
module.exports.validateContactFormJson = validateContactFormJson
module.exports.fetchLivestreamArchiveJson = fetchLivestreamArchiveJson
module.exports.fetchBulletinsJson = fetchBulletinsJson
module.exports.fetchCurrentBulletinJson = fetchCurrentBulletinJson
module.exports.fetchBibleVerseJson = fetchBibleVerseJson
module.exports.submitEventJson = submitEventJson
module.exports.testAdminFunction = testAdminFunction
module.exports.fetchAllSchedulesJson = fetchAllSchedulesJson
module.exports.createScheduleJson = createScheduleJson
module.exports.updateScheduleJson = updateScheduleJson
module.exports.deleteScheduleJson = deleteScheduleJson

View file

@ -1,34 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export declare function getChurchName(): string
export declare function fetchEventsJson(): string
export declare function fetchFeaturedEventsJson(): string
export declare function fetchSermonsJson(): string
export declare function fetchConfigJson(): string
export declare function getMissionStatement(): string
export declare function fetchRandomBibleVerseJson(): string
export declare function getStreamLiveStatus(): boolean
export declare function getLivestreamUrl(): string
export declare function getChurchAddress(): string
export declare function getChurchPhysicalAddress(): string
export declare function getChurchPoBox(): string
export declare function getContactPhone(): string
export declare function getContactEmail(): string
export declare function getFacebookUrl(): string
export declare function getYoutubeUrl(): string
export declare function getInstagramUrl(): string
export declare function submitContactV2Json(name: string, email: string, subject: string, message: string, phone: string): string
export declare function validateContactFormJson(formJson: string): string
export declare function fetchLivestreamArchiveJson(): string
export declare function fetchBulletinsJson(): string
export declare function fetchCurrentBulletinJson(): string
export declare function fetchBibleVerseJson(query: string): string
export declare function submitEventJson(title: string, description: string, startTime: string, endTime: string, location: string, locationUrl: string | undefined | null, category: string, recurringType?: string | undefined | null, submitterEmail?: string | undefined | null): string
export declare function testAdminFunction(): string
export declare function fetchAllSchedulesJson(): string
export declare function createScheduleJson(scheduleJson: string): string
export declare function updateScheduleJson(date: string, updateJson: string): string
export declare function deleteScheduleJson(date: string): string

View file

@ -1,27 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export declare function getChurchName(): string
export declare function fetchEventsJson(): string
export declare function fetchFeaturedEventsJson(): string
export declare function fetchSermonsJson(): string
export declare function fetchConfigJson(): string
export declare function getMissionStatement(): string
export declare function fetchRandomBibleVerseJson(): string
export declare function getStreamLiveStatus(): boolean
export declare function getLivestreamUrl(): string
export declare function getChurchAddress(): string
export declare function getContactPhone(): string
export declare function getContactEmail(): string
export declare function getFacebookUrl(): string
export declare function getYoutubeUrl(): string
export declare function getInstagramUrl(): string
export declare function submitContactV2Json(name: string, email: string, subject: string, message: string, phone: string): string
export declare function validateContactFormJson(formJson: string): string
export declare function fetchLivestreamArchiveJson(): string
export declare function fetchBulletinsJson(): string
export declare function fetchCurrentBulletinJson(): string
export declare function fetchBibleVerseJson(query: string): string
export declare function submitEventJson(title: string, description: string, startTime: string, endTime: string, location: string, locationUrl: string | undefined | null, category: string, recurringType?: string | undefined | null, submitterEmail?: string | undefined | null): string

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,99 +0,0 @@
[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"
# 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"]
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[[bin]]
name = "church-core-test"
path = "src/bin/test.rs"

View file

@ -1,3 +0,0 @@
fn main() {
// No build steps needed
}

View file

@ -1,464 +0,0 @@
use crate::{
ChurchApiClient, ChurchCoreConfig,
models::{NewSchedule, ScheduleUpdate, NewBulletin, BulletinUpdate, NewEvent, EventUpdate},
};
use tokio::runtime::Runtime;
use std::sync::OnceLock;
static CLIENT: OnceLock<ChurchApiClient> = OnceLock::new();
static RT: OnceLock<Runtime> = OnceLock::new();
fn get_client() -> &'static ChurchApiClient {
CLIENT.get_or_init(|| {
let config = ChurchCoreConfig::default();
ChurchApiClient::new(config).expect("Failed to create church client")
})
}
fn get_runtime() -> &'static Runtime {
RT.get_or_init(|| {
Runtime::new().expect("Failed to create async runtime")
})
}
// Configuration functions
pub fn get_church_name() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.church_name.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_contact_phone() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.contact_phone.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_contact_email() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.contact_email.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_church_address() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.church_address.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_church_physical_address() -> String {
get_church_address()
}
pub fn get_church_po_box() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.po_box.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_mission_statement() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.mission_statement.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_facebook_url() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.facebook_url.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_youtube_url() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.youtube_url.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_instagram_url() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => config.instagram_url.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
pub fn get_stream_live_status() -> bool {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_stream_status()) {
Ok(status) => status.is_live,
Err(_) => false,
}
}
pub fn get_livestream_url() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_live_stream()) {
Ok(stream) => stream.stream_title.unwrap_or_else(|| "".to_string()),
Err(e) => format!("Error: {}", e),
}
}
// JSON API functions
pub fn fetch_events_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_upcoming_events(Some(50))) {
Ok(events) => serde_json::to_string(&events).unwrap_or_else(|_| "[]".to_string()),
Err(_) => "[]".to_string(),
}
}
pub fn fetch_featured_events_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_featured_events_v2(Some(10))) {
Ok(events) => serde_json::to_string(&events).unwrap_or_else(|_| "[]".to_string()),
Err(_) => "[]".to_string(),
}
}
pub fn fetch_sermons_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_recent_sermons(Some(20))) {
Ok(sermons) => serde_json::to_string(&sermons).unwrap_or_else(|_| "[]".to_string()),
Err(_) => "[]".to_string(),
}
}
pub fn fetch_config_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_config()) {
Ok(config) => serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string()),
Err(_) => "{}".to_string(),
}
}
pub fn fetch_random_bible_verse_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_random_verse()) {
Ok(verse) => serde_json::to_string(&verse).unwrap_or_else(|_| "{}".to_string()),
Err(_) => "{}".to_string(),
}
}
pub fn fetch_bulletins_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_bulletins(true)) {
Ok(bulletins) => serde_json::to_string(&bulletins).unwrap_or_else(|_| "[]".to_string()),
Err(_) => "[]".to_string(),
}
}
pub fn fetch_current_bulletin_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_current_bulletin()) {
Ok(Some(bulletin)) => serde_json::to_string(&bulletin).unwrap_or_else(|_| "{}".to_string()),
Ok(None) => "{}".to_string(),
Err(_) => "{}".to_string(),
}
}
pub fn fetch_bible_verse_json(query: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_verse_by_reference(&query)) {
Ok(Some(verse)) => serde_json::to_string(&verse).unwrap_or_else(|_| "{}".to_string()),
Ok(None) => "{}".to_string(),
Err(_) => "{}".to_string(),
}
}
pub fn fetch_livestream_archive_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_livestreams()) {
Ok(streams) => serde_json::to_string(&streams).unwrap_or_else(|_| "[]".to_string()),
Err(_) => "[]".to_string(),
}
}
pub fn submit_contact_v2_json(name: String, email: String, subject: String, message: String, phone: String) -> String {
let client = get_client();
let rt = get_runtime();
let contact = crate::models::ContactForm::new(name, email, subject, message)
.with_phone(phone);
match rt.block_on(client.submit_contact_form_v2(contact)) {
Ok(id) => serde_json::to_string(&serde_json::json!({"success": true, "id": id})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn validate_contact_form_json(form_json: String) -> String {
match serde_json::from_str::<crate::models::ContactForm>(&form_json) {
Ok(_) => serde_json::to_string(&crate::utils::ValidationResult::valid()).unwrap_or_else(|_| "{}".to_string()),
Err(_) => serde_json::to_string(&crate::utils::ValidationResult::invalid(vec!["Invalid JSON format".to_string()])).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn submit_event_json(
title: String,
description: String,
start_time: String,
end_time: String,
location: String,
location_url: Option<String>,
category: String,
recurring_type: Option<String>,
submitter_email: Option<String>
) -> String {
let client = get_client();
let rt = get_runtime();
let submission = crate::models::EventSubmission {
title,
description,
start_time,
end_time,
location,
location_url,
category,
recurring_type,
submitter_email: submitter_email.unwrap_or_else(|| "".to_string()),
is_featured: false,
bulletin_week: None,
};
match rt.block_on(client.submit_event(submission)) {
Ok(id) => serde_json::to_string(&serde_json::json!({"success": true, "id": id})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
// Admin functions
pub fn fetch_all_schedules_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.get_all_admin_schedules()) {
Ok(schedules) => serde_json::to_string(&schedules).unwrap_or_else(|_| "[]".to_string()),
Err(_) => "[]".to_string(),
}
}
pub fn create_schedule_json(schedule_json: String) -> String {
let client = get_client();
let rt = get_runtime();
match serde_json::from_str::<NewSchedule>(&schedule_json) {
Ok(schedule) => {
match rt.block_on(client.create_admin_schedule(schedule)) {
Ok(id) => serde_json::to_string(&serde_json::json!({"success": true, "id": id})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
},
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": format!("Invalid JSON: {}", e)})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn update_schedule_json(date: String, update_json: String) -> String {
let client = get_client();
let rt = get_runtime();
match serde_json::from_str::<ScheduleUpdate>(&update_json) {
Ok(update) => {
match rt.block_on(client.update_admin_schedule(&date, update)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
},
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": format!("Invalid JSON: {}", e)})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn delete_schedule_json(date: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.delete_admin_schedule(&date)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
// Admin Auth Functions
pub fn admin_login_json(email: String, password: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.admin_login(&email, &password)) {
Ok(token) => serde_json::to_string(&serde_json::json!({"success": true, "token": token})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn validate_admin_token_json(token: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(client.validate_admin_token(&token)) {
Ok(valid) => serde_json::to_string(&serde_json::json!({"success": true, "valid": valid})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
// Admin Events Functions
pub fn fetch_pending_events_json() -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(crate::client::admin::get_pending_events(client)) {
Ok(events) => serde_json::to_string(&events).unwrap_or_else(|_| "[]".to_string()),
Err(_) => "[]".to_string(),
}
}
pub fn approve_pending_event_json(event_id: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(crate::client::admin::approve_pending_event(client, &event_id)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn reject_pending_event_json(event_id: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(crate::client::admin::reject_pending_event(client, &event_id)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn delete_pending_event_json(event_id: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(crate::client::admin::delete_pending_event(client, &event_id)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn update_admin_event_json(event_id: String, update_json: String) -> String {
let client = get_client();
let rt = get_runtime();
match serde_json::from_str::<EventUpdate>(&update_json) {
Ok(update) => {
match rt.block_on(crate::client::admin::update_admin_event(client, &event_id, update)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
},
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": format!("Invalid JSON: {}", e)})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn delete_admin_event_json(event_id: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(crate::client::admin::delete_admin_event(client, &event_id)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}
// Admin Bulletins Functions
pub fn create_bulletin_json(bulletin_json: String) -> String {
let client = get_client();
let rt = get_runtime();
match serde_json::from_str::<NewBulletin>(&bulletin_json) {
Ok(bulletin) => {
match rt.block_on(crate::client::admin::create_bulletin(client, bulletin)) {
Ok(id) => serde_json::to_string(&serde_json::json!({"success": true, "id": id})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
},
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": format!("Invalid JSON: {}", e)})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn update_bulletin_json(bulletin_id: String, update_json: String) -> String {
let client = get_client();
let rt = get_runtime();
match serde_json::from_str::<BulletinUpdate>(&update_json) {
Ok(update) => {
match rt.block_on(crate::client::admin::update_bulletin(client, &bulletin_id, update)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
},
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": format!("Invalid JSON: {}", e)})).unwrap_or_else(|_| "{}".to_string()),
}
}
pub fn delete_bulletin_json(bulletin_id: String) -> String {
let client = get_client();
let rt = get_runtime();
match rt.block_on(crate::client::admin::delete_bulletin(client, &bulletin_id)) {
Ok(_) => serde_json::to_string(&serde_json::json!({"success": true})).unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string(&serde_json::json!({"success": false, "error": e.to_string()})).unwrap_or_else(|_| "{}".to_string()),
}
}

View file

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

View file

@ -1,36 +0,0 @@
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),
}
}

View file

@ -1,94 +0,0 @@
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(())
}

View file

@ -1,339 +0,0 @@
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

View file

@ -1,78 +0,0 @@
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();
string get_church_physical_address();
string get_church_po_box();
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);
};

View file

@ -1,107 +0,0 @@
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
// Note: Event creation must go through public submission form
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
}

View file

@ -1,169 +0,0 @@
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)
}

View file

@ -1,101 +0,0 @@
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),
}
}

View file

@ -1,50 +0,0 @@
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
}

View file

@ -1,125 +0,0 @@
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())))
}
}

View file

@ -1,192 +0,0 @@
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
}

View file

@ -1,402 +0,0 @@
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)
}
}

View file

@ -1,35 +0,0 @@
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
}

View file

@ -1,446 +0,0 @@
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 - Edit/delete allowed, creation via submission form only
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)
}
// Admin Auth operations
pub async fn admin_login(&self, email: &str, password: &str) -> Result<String> {
let url = self.build_url("/auth/login");
let request_body = serde_json::json!({
"email": email,
"password": password
});
let response = self.client
.post(&url)
.json(&request_body)
.send()
.await?;
if response.status().is_success() {
let auth_response: serde_json::Value = response.json().await?;
if let Some(token) = auth_response.get("token").and_then(|t| t.as_str()) {
Ok(token.to_string())
} else {
Err(crate::error::ChurchApiError::Api("No token in response".to_string()))
}
} else {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
Err(crate::error::ChurchApiError::Api(error_text))
}
}
pub async fn validate_admin_token(&self, token: &str) -> Result<bool> {
let url = self.build_url("/admin/events/pending");
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await?;
Ok(response.status().is_success())
}
}

View file

@ -1,237 +0,0 @@
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)
}

View file

@ -1,119 +0,0 @@
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())
))
}
}

View file

@ -1,69 +0,0 @@
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
}
}

View file

@ -1,62 +0,0 @@
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(_))
}
}

View file

@ -1,12 +0,0 @@
// 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,
};

View file

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

View file

@ -1,144 +0,0 @@
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 id: String,
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>,
// Personnel assignments
pub song_leader: Option<String>,
pub ss_teacher: Option<String>,
pub ss_leader: Option<String>,
pub mission_story: Option<String>,
pub special_program: Option<String>,
pub sermon_speaker: Option<String>,
pub scripture: Option<String>,
pub offering: Option<String>,
pub deacons: Option<String>,
pub special_music: Option<String>,
pub childrens_story: Option<String>,
pub afternoon_program: 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>,
// Personnel assignments
pub song_leader: Option<String>,
pub ss_teacher: Option<String>,
pub ss_leader: Option<String>,
pub mission_story: Option<String>,
pub special_program: Option<String>,
pub sermon_speaker: Option<String>,
pub scripture: Option<String>,
pub offering: Option<String>,
pub deacons: Option<String>,
pub special_music: Option<String>,
pub childrens_story: Option<String>,
pub afternoon_program: 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>,
// Personnel assignments
#[serde(skip_serializing_if = "Option::is_none")]
pub song_leader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ss_teacher: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ss_leader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mission_story: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_program: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sermon_speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offering: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deacons: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_music: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub childrens_story: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub afternoon_program: 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,
}

View file

@ -1,276 +0,0 @@
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",
],
}
}
}

View file

@ -1,136 +0,0 @@
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",
}
}
}

View file

@ -1,240 +0,0 @@
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()
}
}

View file

@ -1,344 +0,0 @@
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(),
RecurringType::SecondThirdSaturday => "2nd/3rd Saturday Monthly".to_string(),
}
}
}

View file

@ -1,73 +0,0 @@
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
}
}

View file

@ -1,253 +0,0 @@
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()
}
}

View file

@ -1,339 +0,0 @@
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()
}
}

View file

@ -1,349 +0,0 @@
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(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approved_from: Option<String>,
#[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,
#[serde(rename = "2nd/3rd Saturday Monthly")]
SecondThirdSaturday,
}
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>,
}

View file

@ -1,28 +0,0 @@
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};

View file

@ -1,376 +0,0 @@
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(),
}
}
}

View file

@ -1,157 +0,0 @@
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");
}
}

View file

@ -1,15 +0,0 @@
/// 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/",
}
}
}

View file

@ -1,310 +0,0 @@
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"));
}
}

View file

@ -1,159 +0,0 @@
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));
}
}

View file

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

View file

@ -1,164 +0,0 @@
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");
}
}

View file

@ -1,249 +0,0 @@
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(""));
}
}

View file

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -30,6 +30,7 @@ export const {
fetchCurrentBulletinJson,
fetchBibleVerseJson,
submitEventJson,
submitEventWithImageJson,
// Admin functions
fetchAllSchedulesJson,
createScheduleJson,

Some files were not shown because too many files have changed in this diff Show more