Compare commits

..

No commits in common. "91a1bb7a54d08ff845c621c82bc84b6881644b27" and "7f711f7fbe8d51000da612f8f33ef784821555d8" have entirely different histories.

123 changed files with 17898 additions and 6210 deletions

23
.gitignore vendored
View file

@ -7,16 +7,11 @@ 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
@ -36,21 +31,10 @@ 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
@ -60,10 +44,3 @@ ehthumbs.db
# Temporary files
*.tmp
*.temp
# Cargo build metadata
.cargo/
# Coverage reports
coverage/
*.lcov

View file

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

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

@ -1,52 +0,0 @@
# 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)

View file

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

@ -1,104 +0,0 @@
# Admin Panel Architecture Conversion & Cleanup
## Summary
Converted the 1800+ line vanilla JavaScript admin panel to proper Astro routes following CLAUDE.md architecture rules. Removed thumbnail field from event submission form as requested.
## Files Changed
### ✅ REMOVED (Architecture Violations)
- `astro-church-website/public/admin/scripts/main.js` - 1843 lines of vanilla JS making direct API calls
- `astro-church-website/public/admin/` - Entire directory removed
### ✅ ADDED (Proper Architecture)
**New Admin Routes (Astro + TypeScript):**
- `astro-church-website/src/pages/admin/events.astro` - Events management UI
- `astro-church-website/src/pages/admin/bulletins.astro` - Bulletins management UI
- `astro-church-website/src/pages/admin/api/events.ts` - Events API endpoints
- `astro-church-website/src/pages/admin/api/events/[id].ts` - Event CRUD operations
- `astro-church-website/src/pages/admin/api/events/[id]/approve.ts` - Event approval
- `astro-church-website/src/pages/admin/api/events/[id]/reject.ts` - Event rejection
- `astro-church-website/src/pages/admin/api/bulletins.ts` - Bulletins API endpoints
- `astro-church-website/src/pages/admin/api/bulletins/[id].ts` - Bulletin CRUD operations
- `astro-church-website/src/pages/admin/api/auth/login.ts` - Admin authentication
### ✅ ENHANCED (Rust Core Functions)
**church-core/src/api.rs** - Added missing admin functions:
- Auth: `admin_login_json()`, `validate_admin_token_json()`
- Events: `fetch_pending_events_json()`, `approve_pending_event_json()`, `reject_pending_event_json()`, `delete_pending_event_json()`, `create_admin_event_json()`, `update_admin_event_json()`, `delete_admin_event_json()`
- Bulletins: `create_bulletin_json()`, `update_bulletin_json()`, `delete_bulletin_json()`
**church-core/src/client/mod.rs** - Added auth methods:
- `admin_login()` - Handles admin authentication
- `validate_admin_token()` - Validates admin session tokens
### ✅ UPDATED (Bindings & Config)
**astro-church-website/src/lib/bindings.js** - Added exports for new admin functions
**astro-church-website/DEPLOYMENT.md** - Updated deployment instructions
### ✅ THUMBNAIL FIELD REMOVAL
**astro-church-website/src/pages/events/submit.astro** - Removed:
- Thumbnail upload section (HTML form elements)
- Thumbnail JavaScript handling code
- Thumbnail file processing in form submission
## Architecture Compliance Achieved
### ✅ BEFORE (Violations)
```
Frontend → fetch() → External API directly
```
### ✅ AFTER (Correct)
```
Frontend (Astro) → bindings.js → Rust FFI → church-core → API
```
## Results
- **Removed 1843 lines** of architecture-violating vanilla JavaScript
- **Added proper TypeScript Astro routes** following the architecture
- **All admin functionality** now goes through the Rust core
- **Build verified** - project compiles and runs successfully
- **Bundle size reduced** - cleaner, more efficient code
## Next Steps Required
### 🔄 Additional Architecture Violations to Fix
1. **Event Submission Still Direct API** (`src/pages/events/submit.astro:748`)
- Currently: `fetch('https://api.rockvilletollandsda.church/api/events/submit')`
- Should: Use `submitEventJson()` from bindings
2. **Missing Admin Functions in Rust Core**
- Admin stats/dashboard data
- Configuration management (recurring types)
- User management functions
3. **Data Model Mismatches** (from CLAUDE.md)
- Frontend expects Schedule fields: `song_leader`, `childrens_story`
- Check if Rust Schedule model has these fields
4. **Upload Functionality**
- Current admin JS had image upload logic
- Need to implement via Rust upload functions
### 🧹 Code Cleanup Opportunities
1. **Dead Code in Rust** (warnings from build)
- `church-core/src/models/streaming.rs:1` - unused Serialize/Deserialize
- `church-core/src/utils/formatting.rs:56` - unused FixedOffset
- `church-core/src/client/http.rs:197` - unused delete method
2. **Validation Enhancement**
- Add proper TypeScript types for admin API responses
- Add client-side validation for admin forms
3. **Error Handling**
- Replace generic `alert()` calls with proper error UI
- Add loading states for admin operations
## Testing Required
- [ ] Admin login functionality
- [ ] Event approval/rejection workflow
- [ ] Bulletin CRUD operations
- [ ] Schedule management (existing)
- [ ] Event submission without thumbnail field

123
CLAUDE.md
View file

@ -1,123 +0,0 @@
# CLAUDE INSTRUCTIONS - RTSDA CODEBASE
## **CRITICAL ARCHITECTURE UNDERSTANDING**
### **ASTRO IS A THIN UI LAYER ONLY**
- Astro handles routing, SSR, and basic UI rendering
- **NO BUSINESS LOGIC IN FRONTEND**
- **NO DIRECT API CALLS FROM FRONTEND**
- **ALL LOGIC GOES THROUGH `church-core` RUST CRATE**
### **THE RUST CRATE IS THE CORE FOR A FUCKING REASON**
- `church-core/` contains ALL business logic
- `church-core/` handles ALL API communication
- `church-core/` provides unified data models
- `church-core/` has caching, error handling, auth
- **USE THE RUST CRATE, DON'T BYPASS IT**
## ✅ **FIXED VIOLATIONS**
### **Admin Panel (COMPLETED ✅)**
- ~~`public/admin/scripts/main.js` - 1800+ lines of vanilla JS~~ **REMOVED**
- ✅ **NOW:** Proper Astro routes using Rust functions via FFI bindings
- ✅ **FIXED:** Frontend now follows core architecture
## **REMAINING VIOLATIONS TO FIX**
### **Event Submission Direct API Call**
- `src/pages/events/submit.astro:748` - Still uses direct fetch() call
- **SHOULD BE:** Use `submitEventJson()` from bindings.js
- **VIOLATION:** Bypassing the unified client architecture
### **Data Model Mismatches**
- Frontend expects detailed Schedule fields (`song_leader`, `childrens_story`)
- Backend Rust models missing these fields
- **CAUSE:** Frontend developed independently of core models
## **CORRECT ARCHITECTURE FLOW**
```
Frontend (Astro) → bindings.js → Rust FFI → church-core → API
```
**NOT:**
```
Frontend → fetch() → API directly
```
## **HOW TO FIX VIOLATIONS**
### **For Event Submission:**
1. ✅ Admin functions already exist in `church-core/src/client/admin.rs`
2. ✅ Already exposed via FFI in `church-core/src/api.rs`
3. ✅ Already imported in `astro-church-website/src/lib/bindings.js`
4. **TODO:** Replace `fetch()` call with `submitEventJson()` function
### **For Data Models:**
1. Update Schedule models in `church-core/src/models/admin.rs`
2. Add missing fields (`song_leader`, `childrens_story`, etc.)
3. Regenerate FFI bindings
4. Update frontend to use new model structure
## **COMMANDS TO REMEMBER**
### **Build Commands:**
```bash
# Build Rust bindings FIRST
npm run build:native
# Then build Astro
npm run build
# Dev server
npm run dev
```
### **Testing Commands:**
```bash
# Test Rust core
cd church-core && cargo test
# Test API connectivity
cd church-core && cargo run --bin church-core-test
```
## **BEFORE MAKING CHANGES**
1. **Check if Rust function exists** in `church-core/`
2. **If missing, ADD IT TO RUST FIRST**
3. **Then expose via FFI bindings**
4. **Finally update frontend to use it**
## **DO NOT:**
- Add business logic to JavaScript
- Make direct API calls from frontend
- Create data models in frontend
- Bypass the Rust crate architecture
## **THE RUST CRATE EXISTS FOR:**
- Cross-platform consistency (iOS, Android, Web)
- Type safety and error handling
- Unified caching and auth
- Single source of truth for API communication
**RESPECT THE ARCHITECTURE. USE THE RUST CRATE.**
## **CLEANUP PROGRESS TRACKING**
### ✅ **COMPLETED (Aug 29, 2025)**
- **Admin Panel Conversion**: Removed 1843-line vanilla JS file
- **Proper Astro Routes**: Created TypeScript admin routes using Rust functions
- **Thumbnail Field Removal**: Cleaned up event submission form
- **FFI Functions**: Added 12 new admin functions in church-core
- **Architecture Compliance**: Admin panel now follows correct flow
**Commit:** `f91f696` - Convert admin panel to Astro routes and remove thumbnail field
### 🚨 **HIGH PRIORITY NEXT**
1. Fix event submission direct API call (`src/pages/events/submit.astro:748`)
2. Check Schedule model data field mismatches
3. Add missing admin functions (stats, config, users)
**See:** `/NEXT-STEPS.md` for detailed implementation plan

View file

@ -1,113 +0,0 @@
# Next Steps for Architecture Cleanup
## 🚨 Priority 1: Critical Architecture Violations
### 1. Fix Event Submission Direct API Call
**Location:** `astro-church-website/src/pages/events/submit.astro:748`
**Issue:** Still uses `fetch('https://api.rockvilletollandsda.church/api/events/submit')`
**Fix:** Replace with `submitEventJson()` function from bindings
### 2. Data Model Mismatches
**Issue:** Frontend expects Schedule fields that may not exist in Rust models
**Check:** Does `church-core/src/models/admin.rs` Schedule struct have:
- `song_leader`
- `childrens_story`
- `ss_teacher`
- `ss_leader`
- `mission_story`
- `special_notes`
**Fix:** Update Rust Schedule model if fields are missing
## 🧹 Priority 2: Code Cleanup
### 1. Remove Dead Code (Rust Warnings)
```bash
# Fix these warnings from build output:
cargo fix --lib -p church-core
```
**Locations:**
- `church-core/src/models/streaming.rs:1` - unused Serialize/Deserialize
- `church-core/src/utils/formatting.rs:56` - unused FixedOffset
- `church-core/src/client/http.rs:197` - unused delete method
### 2. Missing Admin Functions
**Add to `church-core/src/api.rs`:**
- `fetch_admin_stats_json()` - Dashboard statistics
- `fetch_recurring_types_json()` - Configuration data
- `get_admin_users_json()` - User management
### 3. Upload Functionality
**Issue:** Old admin JS had image upload logic not yet converted
**Add to `church-core/src/api.rs`:**
- `upload_event_image_json()`
- `upload_bulletin_cover_json()`
## 🔄 Priority 3: Enhanced Admin Features
### 1. Add TypeScript Types
**Create:** `astro-church-website/src/types/admin.ts`
- Define admin API response types
- Add type safety for admin operations
### 2. Improve Error Handling
**Replace:** Generic `alert()` calls in admin pages
**With:** Proper error UI components
### 3. Add Loading States
**Add to admin pages:**
- Loading spinners for operations
- Disabled states during API calls
- Better UX feedback
## 🎯 Priority 4: Testing & Validation
### 1. Test New Admin Functionality
- [ ] Login/logout flow
- [ ] Event approval/rejection
- [ ] Bulletin CRUD operations
- [ ] Schedule management
- [ ] Event submission without thumbnail
### 2. Add Form Validation
- [ ] Client-side validation for admin forms
- [ ] Real-time feedback
- [ ] Error message display
## 📝 Implementation Order
1. **Immediate (blocking):** Fix event submission API call
2. **High:** Check/fix Schedule model data mismatches
3. **Medium:** Add missing admin functions
4. **Low:** UI/UX improvements and TypeScript types
## 🔧 Commands to Run
```bash
# Test current state
cd astro-church-website
npm run build
npm run dev
# Fix Rust warnings
cd ../church-core
cargo fix --lib -p church-core
# Rebuild after Rust changes
cd ../astro-church-website
npm run build:native
npm run build
```
## 🏆 Architecture Success Metrics
**✅ ACHIEVED:**
- Removed 1843 lines of direct API call violations
- All admin routes now use Rust core functions
- Proper Astro/TypeScript structure implemented
**🎯 TARGET:**
- Zero direct API calls from frontend
- All business logic in Rust crate
- Complete type safety with TypeScript
- Full test coverage for admin operations

123
README.md
View file

@ -1,43 +1,104 @@
# Astro Starter Kit: Minimal
# RTSDA Website
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
```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/
astro-church-website/ # Frontend Astro application
├── src/
│ └── pages/
│ └── index.astro
└── package.json
│ ├── 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
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
## Development
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
### Prerequisites
Any static assets, like images, can be placed in the `public/` directory.
- Node.js 18+ and npm
- Rust 1.70+
- Cargo
## 🧞 Commands
### Setup
All commands are run from the root of the project, from a terminal:
1. **Install dependencies:**
```bash
cd astro-church-website
npm install
```
| 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 |
2. **Build Rust bindings:**
```bash
npm run build:native
```
## 👀 Want to learn more?
3. **Start development server:**
```bash
npm run dev
```
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
### Building for Production
1. **Build native bindings:**
```bash
npm run build:native
```
2. **Build Astro site:**
```bash
npm run build
```
3. **Deploy to web server:**
```bash
cp -r dist/* /var/www/rtsda-website/
```
## 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.

24
astro-church-website/.gitignore vendored Normal file
View file

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

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

View file

@ -7,12 +7,12 @@
src/pages/admin/index.astro # Main admin dashboard page
src/components/admin/Login.astro # Admin login component
src/pages/bulletin/[id].astro # Fixed bulletin detail page (SSR)
src/pages/admin/ # New Astro admin routes (TypeScript)
public/admin/scripts/main.js # Admin JavaScript (if not already there)
```
### Verify these files exist on server:
```
src/pages/admin/ # New admin routes using Rust bindings
public/admin/scripts/main.js # Admin functionality
```
## Deployment Steps
@ -26,8 +26,8 @@ src/pages/admin/ # New admin routes using Rust bindings
# Copy fixed bulletin page
scp src/pages/bulletin/[id].astro user@server:/opt/rtsda/src/pages/bulletin/
# Copy new admin API routes
scp -r src/pages/admin/api/ user@server:/opt/rtsda/src/pages/admin/
# Verify admin scripts exist
scp public/admin/scripts/main.js user@server:/opt/rtsda/public/admin/scripts/
```
2. **SSH into server:**

View file

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

@ -0,0 +1,338 @@
/* 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 } = 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

29
astro-church-website/index.d.ts vendored Normal file
View file

@ -0,0 +1,29 @@
/* 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

View file

@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { getChurchName, fetchEventsJson, fetchFeaturedEventsJson, fetchSermonsJson, fetchConfigJson, getMissionStatement, fetchRandomBibleVerseJson, getStreamLiveStatus, getLivestreamUrl, getChurchAddress, getContactPhone, getContactEmail, getFacebookUrl, getYoutubeUrl, getInstagramUrl, submitContactV2Json, validateContactFormJson, fetchLivestreamArchiveJson, fetchBulletinsJson, fetchCurrentBulletinJson, fetchBibleVerseJson, submitEventJson } = nativeBinding
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 } = nativeBinding
module.exports.getChurchName = getChurchName
module.exports.fetchEventsJson = fetchEventsJson
@ -322,6 +322,8 @@ 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

6752
astro-church-website/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,11 @@
{
"name": "astro-church-website",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "npm run build:native && npm run build:themes && astro build",
"build:native": "napi build --platform --release --js index.cjs",
"build:native": "napi build --platform --release",
"build:themes": "npm run build:theme-light && npm run build:theme-dark",
"build:theme-light": "tailwindcss -c tailwind.light.config.mjs -i ./src/styles/theme-input.css -o ./public/css/theme-light.css --minify",
"build:theme-dark": "tailwindcss -c tailwind.dark.config.mjs -i ./src/styles/theme-input.css -o ./public/css/theme-dark.css --minify",
@ -24,6 +25,7 @@
},
"napi": {
"name": "church-core-bindings",
"moduleType": "cjs",
"triples": {
"defaults": true,
"additional": [

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

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

@ -1,120 +1,119 @@
use napi_derive::napi;
use church_core;
use church_core::api;
#[napi]
pub fn get_church_name() -> String {
api::get_church_name()
church_core::get_church_name()
}
#[napi]
pub fn fetch_events_json() -> String {
api::fetch_events_json()
church_core::fetch_events_json()
}
#[napi]
pub fn fetch_featured_events_json() -> String {
api::fetch_featured_events_json()
church_core::fetch_featured_events_json()
}
#[napi]
pub fn fetch_sermons_json() -> String {
api::fetch_sermons_json()
church_core::fetch_sermons_json()
}
#[napi]
pub fn fetch_config_json() -> String {
api::fetch_config_json()
church_core::fetch_config_json()
}
#[napi]
pub fn get_mission_statement() -> String {
api::get_mission_statement()
church_core::get_mission_statement()
}
#[napi]
pub fn fetch_random_bible_verse_json() -> String {
api::fetch_random_bible_verse_json()
church_core::fetch_random_bible_verse_json()
}
#[napi]
pub fn get_stream_live_status() -> bool {
api::get_stream_live_status()
church_core::get_stream_live_status()
}
#[napi]
pub fn get_livestream_url() -> String {
api::get_livestream_url()
church_core::get_livestream_url()
}
#[napi]
pub fn get_church_address() -> String {
api::get_church_address()
church_core::get_church_address()
}
#[napi]
pub fn get_church_physical_address() -> String {
api::get_church_physical_address()
church_core::get_church_physical_address()
}
#[napi]
pub fn get_church_po_box() -> String {
api::get_church_po_box()
church_core::get_church_po_box()
}
#[napi]
pub fn get_contact_phone() -> String {
api::get_contact_phone()
church_core::get_contact_phone()
}
#[napi]
pub fn get_contact_email() -> String {
api::get_contact_email()
church_core::get_contact_email()
}
#[napi]
pub fn get_facebook_url() -> String {
api::get_facebook_url()
church_core::get_facebook_url()
}
#[napi]
pub fn get_youtube_url() -> String {
api::get_youtube_url()
church_core::get_youtube_url()
}
#[napi]
pub fn get_instagram_url() -> String {
api::get_instagram_url()
church_core::get_instagram_url()
}
#[napi]
pub fn submit_contact_v2_json(name: String, email: String, subject: String, message: String, phone: String) -> String {
api::submit_contact_v2_json(name, email, subject, message, phone)
church_core::submit_contact_v2_json(name, email, subject, message, phone)
}
#[napi]
pub fn validate_contact_form_json(form_json: String) -> String {
api::validate_contact_form_json(form_json)
church_core::validate_contact_form_json(form_json)
}
#[napi]
pub fn fetch_livestream_archive_json() -> String {
api::fetch_livestream_archive_json()
church_core::fetch_livestream_archive_json()
}
#[napi]
pub fn fetch_bulletins_json() -> String {
api::fetch_bulletins_json()
church_core::fetch_bulletins_json()
}
#[napi]
pub fn fetch_current_bulletin_json() -> String {
api::fetch_current_bulletin_json()
church_core::fetch_current_bulletin_json()
}
#[napi]
pub fn fetch_bible_verse_json(query: String) -> String {
api::fetch_bible_verse_json(query)
church_core::fetch_bible_verse_json(query)
}
#[napi]
@ -129,7 +128,7 @@ pub fn submit_event_json(
recurring_type: Option<String>,
submitter_email: Option<String>
) -> String {
api::submit_event_json(
church_core::submit_event_json(
title,
description,
start_time,
@ -142,28 +141,4 @@ pub fn submit_event_json(
)
}
// Admin functions
#[napi]
pub fn test_admin_function() -> String {
"test".to_string()
}
#[napi]
pub fn fetch_all_schedules_json() -> String {
api::fetch_all_schedules_json()
}
#[napi]
pub fn create_schedule_json(schedule_json: String) -> String {
api::create_schedule_json(schedule_json)
}
#[napi]
pub fn update_schedule_json(date: String, update_json: String) -> String {
api::update_schedule_json(date, update_json)
}
#[napi]
pub fn delete_schedule_json(date: String) -> String {
api::delete_schedule_json(date)
}
// Admin functions removed due to API changes

View file

@ -29,25 +29,5 @@ export const {
fetchBulletinsJson,
fetchCurrentBulletinJson,
fetchBibleVerseJson,
submitEventJson,
submitEventWithImageJson,
// Admin functions
fetchAllSchedulesJson,
createScheduleJson,
updateScheduleJson,
deleteScheduleJson,
// Admin auth
adminLoginJson,
validateAdminTokenJson,
// Admin events
fetchPendingEventsJson,
approvePendingEventJson,
rejectPendingEventJson,
deletePendingEventJson,
updateAdminEventJson,
deleteAdminEventJson,
// Admin bulletins
createBulletinJson,
updateBulletinJson,
deleteBulletinJson
submitEventJson
} = nativeBindings;

View file

@ -0,0 +1,484 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import Login from '../../components/admin/Login.astro';
// Ensure this page uses server-side rendering
export const prerender = false;
---
<MainLayout title="Church Admin Dashboard" description="Administrative dashboard for church management">
<Login />
<div id="dashboard" class="hidden">
<!-- Header -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<span class="text-white text-lg">⛪</span>
</div>
<div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">Admin Dashboard</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Welcome back, Admin</div>
</div>
</div>
<button onclick="handleLogout()" class="inline-flex items-center space-x-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors">
<span>👋</span>
<span>Logout</span>
</button>
</div>
</div>
<!-- Main Content -->
<div class="max-w-6xl mx-auto p-6">
<!-- Stats Grid -->
<div id="statsGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Pending Events Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="loadPending()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Pending Events</h3>
<p id="pendingCount" class="text-2xl font-bold text-orange-600 dark:text-orange-400">-</p>
</div>
<div class="w-12 h-12 bg-orange-100 dark:bg-orange-900 rounded-lg flex items-center justify-center">
<span class="text-xl">⏳</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-orange-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Awaiting review</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to view →</span>
</div>
</div>
<!-- Total Events Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="loadAllEvents()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Events</h3>
<p id="totalCount" class="text-2xl font-bold text-blue-600 dark:text-blue-400">-</p>
</div>
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<span class="text-xl">📅</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Published events</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to view →</span>
</div>
</div>
<!-- Bulletins Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="showBulletins()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Bulletins</h3>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">Manage</p>
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
<span class="text-xl">🗞️</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-purple-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Content management</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to manage →</span>
</div>
</div>
<!-- Schedules Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="showSchedules()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Schedules</h3>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">Manage</p>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<span class="text-xl">👥</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Personnel scheduling</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to manage →</span>
</div>
</div>
</div>
<!-- Content Area -->
<div id="content" class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="text-center py-12">
<div class="text-4xl mb-4">🎉</div>
<p class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Welcome to the Admin Dashboard!</p>
<p class="text-gray-600 dark:text-gray-400">Click on the cards above to get started managing your church events.</p>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 id="modalTitle" class="text-xl font-semibold text-gray-900 dark:text-white"></h2>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-2xl">
×
</button>
</div>
<div id="modalContent" class="p-6">
<!-- Modal content populated by JS -->
</div>
<div id="modalActions" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<!-- Modal actions populated by JS -->
</div>
</div>
</div>
<!-- Include admin JavaScript -->
<script is:inline src="/admin/scripts/main.js"></script>
<!-- Debug script to check what's happening -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
console.log('Admin page loaded');
// Log what styles are being applied
setTimeout(() => {
const eventItems = document.querySelectorAll('.event-item');
console.log('Found event items:', eventItems.length);
eventItems.forEach((item, index) => {
console.log(`Event item ${index}:`, item);
console.log('Computed styles:', window.getComputedStyle(item));
});
}, 2000);
});
</script>
</MainLayout>
<style is:global>
.hidden {
display: none !important;
}
/* Basic styles for dynamically created content using standard CSS */
.btn-edit,
.btn-action.btn-edit {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: rgb(37 99 235);
color: white;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-edit:hover,
.btn-action.btn-edit:hover {
background-color: rgb(29 78 216);
}
.btn-delete,
.btn-action.btn-delete {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: rgb(220 38 38);
color: white;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-delete:hover,
.btn-action.btn-delete:hover {
background-color: rgb(185 28 28);
}
.btn-action {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-action.btn-approve,
.btn-approve {
background-color: rgb(34 197 94);
color: white;
}
.btn-action.btn-approve:hover,
.btn-approve:hover {
background-color: rgb(21 128 61);
}
.btn-action.btn-reject,
.btn-reject {
background-color: rgb(220 38 38);
color: white;
}
.btn-action.btn-reject:hover,
.btn-reject:hover {
background-color: rgb(185 28 28);
}
.content-badge {
@apply px-3 py-1 rounded-full text-sm font-medium;
}
.content-badge.pending {
@apply bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200;
}
.content-badge.total {
@apply bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200;
}
.content-badge.purple {
@apply bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200;
}
.event-item {
background-color: rgb(31 41 55);
border-radius: 0.75rem;
border: 1px solid rgb(55 65 81);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.event-content {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.event-image {
width: 8rem;
height: 8rem;
background-color: rgb(55 65 81);
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.875rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgb(75 85 99);
}
.event-image img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.5rem;
}
.event-details {
flex: 1;
min-width: 0;
}
.event-title {
font-size: 1.25rem;
font-weight: 700;
color: rgb(243 244 246);
margin-bottom: 0.75rem;
line-height: 1.375;
}
.event-description {
color: rgb(156 163 175);
margin-bottom: 1rem;
line-height: 1.625;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.event-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
font-size: 0.875rem;
color: rgb(156 163 175);
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.meta-item span:first-child {
flex-shrink: 0;
}
.event-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex-shrink: 0;
margin-left: 1rem;
}
@media (max-width: 768px) {
.event-content {
flex-direction: column;
gap: 1rem;
}
.event-actions {
flex-direction: row;
margin-left: 0;
}
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.content-title {
font-size: 1.5rem;
font-weight: 700;
color: rgb(243 244 246);
}
.content-title span {
margin-right: 0.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 0;
}
.empty-icon {
font-size: 2.25rem;
margin-bottom: 1rem;
}
.empty-icon.success {
color: rgb(34 197 94);
}
.empty-icon.info {
color: rgb(59 130 246);
}
.empty-icon.purple {
color: rgb(168 85 247);
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: rgb(243 244 246);
margin-bottom: 0.5rem;
}
.empty-state p:not(.empty-title) {
color: rgb(156 163 175);
}
.category-badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.category-badge.featured {
background-color: rgb(220 252 231);
color: rgb(22 101 52);
}
.category-badge.approved {
background-color: rgb(243 244 246);
color: rgb(55 65 81);
}
.category-badge.pending {
background-color: rgb(255 237 213);
color: rgb(154 52 18);
}
.form-grid {
@apply space-y-4;
}
.form-grid.cols-2 {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.form-grid label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2;
}
.form-grid input,
.form-grid textarea,
.form-grid select {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
.progress-bar {
@apply w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2;
}
.progress-fill {
@apply bg-blue-600 h-2 rounded-full transition-all duration-300;
}
/* Text truncation utility */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Better text rendering for event descriptions */
.event-description p {
@apply mb-2;
}
.event-description p:last-child {
@apply mb-0;
}
/* Ensure text content doesn't break layout */
.event-details * {
word-wrap: break-word;
overflow-wrap: break-word;
}
</style>

View file

@ -2,20 +2,23 @@
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName, fetchEventsJson } from '../../lib/bindings.js';
const { id } = Astro.params;
export async function getStaticPaths() {
try {
const eventsJson = fetchEventsJson();
const parsedEvents = JSON.parse(eventsJson);
const events = Array.isArray(parsedEvents) ? parsedEvents : (parsedEvents.items || []);
const event = events.find(e => e.id === id);
if (!event) {
return new Response(null, {
status: 404,
statusText: 'Not found'
});
return events.map((event) => ({
params: { id: event.id },
props: { event }
}));
} catch (e) {
console.error('Failed to generate event paths:', e);
return [];
}
}
const { event } = Astro.props;
let churchName = 'Church';
try {

View file

@ -1,6 +1,6 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName, submitEventJson } from '../../lib/bindings.js';
import { getChurchName } from '../../lib/bindings.js';
let churchName = 'Church';
@ -311,6 +311,46 @@ try {
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Optional: Add a photo to make your event stand out</p>
</div>
<div>
<label for="thumbnail" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Thumbnail Image
</label>
<div class="relative">
<input
type="file"
id="thumbnail"
name="thumbnail"
accept="image/*"
class="hidden"
/>
<label
for="thumbnail"
class="flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl cursor-pointer bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div class="flex flex-col items-center justify-center py-2">
<i data-lucide="image" class="w-6 h-6 text-gray-400 mb-1"></i>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
<span class="font-semibold">Click to upload</span> thumbnail
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">Smaller image for listings</p>
</div>
</label>
</div>
<div id="thumbnailPreview" class="mt-4 hidden">
<div class="relative inline-block">
<img id="thumbnailPreviewImg" src="" alt="Thumbnail Preview" class="w-32 h-20 object-cover rounded-lg border border-gray-200 dark:border-gray-600" />
<button
type="button"
id="removeThumbnail"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<p id="thumbnailInfo" class="text-sm text-gray-500 dark:text-gray-400 mt-2"></p>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Optional: Smaller image for event listings</p>
</div>
</div>
</div>
@ -361,8 +401,6 @@ try {
</MainLayout>
<script>
// Make Rust functions available to client-side script
const { submitEventJson: rustSubmitEvent, submitEventWithImageJson: rustSubmitEventWithImage } = await import('../../lib/bindings.js');
// Function to load recurring types from API
async function loadRecurringTypes() {
@ -488,8 +526,9 @@ try {
});
}
// Setup image upload
// Setup both image uploads
setupImageUpload('image', 'imagePreview', 'imagePreviewImg', 'imageInfo', 'removeImage');
setupImageUpload('thumbnail', 'thumbnailPreview', 'thumbnailPreviewImg', 'thumbnailInfo', 'removeThumbnail');
// Real-time validation
function setupRealtimeValidation() {
@ -728,55 +767,57 @@ try {
const locationUrl = formData.get('location_url') as string;
const recurringType = formData.get('recurring_type') as string;
// Get image file if selected
// Submit to API endpoint (since we can't use church-core directly in browser)
const formDataToSubmit = new FormData();
formDataToSubmit.append('title', title);
formDataToSubmit.append('description', description);
formDataToSubmit.append('start_time', startTimeISO);
formDataToSubmit.append('end_time', endTimeISO);
formDataToSubmit.append('location', location);
formDataToSubmit.append('category', category);
if (locationUrl) formDataToSubmit.append('location_url', locationUrl);
if (recurringType) formDataToSubmit.append('recurring_type', recurringType);
if (email) formDataToSubmit.append('submitter_email', email);
// Add images if selected
const imageFile = (document.getElementById('image') as HTMLInputElement).files?.[0];
const thumbnailFile = (document.getElementById('thumbnail') as HTMLInputElement).files?.[0];
let resultJson;
if (imageFile) formDataToSubmit.append('image', imageFile);
if (thumbnailFile) formDataToSubmit.append('thumbnail', thumbnailFile);
if (imageFile) {
// Convert image file to bytes for Rust function
const imageArrayBuffer = await imageFile.arrayBuffer();
const imageBytes = new Uint8Array(imageArrayBuffer);
// Use church-core Rust function with image support
resultJson = rustSubmitEventWithImage(
title,
description,
startTimeISO,
endTimeISO,
location,
locationUrl || null,
category,
recurringType || null,
email || null,
Array.from(imageBytes), // Convert to regular array for FFI
imageFile.name
);
} else {
// Use standard church-core Rust function without image
resultJson = rustSubmitEvent(
title,
description,
startTimeISO,
endTimeISO,
location,
locationUrl || null,
category,
recurringType || null,
email || null
);
}
const result = JSON.parse(resultJson);
// Submit to the API endpoint (same as the legacy form)
const response = await fetch('https://api.rockvilletollandsda.church/api/events/submit', {
method: 'POST',
body: formDataToSubmit
});
if (response.ok) {
try {
const result = await response.json();
if (result.success) {
loadingSpinner.classList.add('hidden');
successMessage.classList.remove('hidden');
form.reset();
// Clear image preview
// Clear image previews
document.getElementById('imagePreview')?.classList.add('hidden');
document.getElementById('thumbnailPreview')?.classList.add('hidden');
} else {
throw new Error(result.error || 'Submission failed');
throw new Error(result.message || 'Submission failed');
}
} catch (jsonError) {
// If JSON parsing fails but response is OK, assume success
loadingSpinner.classList.add('hidden');
successMessage.classList.remove('hidden');
form.reset();
// Clear image previews
document.getElementById('imagePreview')?.classList.add('hidden');
document.getElementById('thumbnailPreview')?.classList.add('hidden');
}
} else {
const errorText = await response.text();
throw new Error(`Server error: ${response.status} - ${errorText || 'Unknown error'}`);
}
} catch (error) {

113
church-core/Cargo.toml Normal file
View file

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

6
church-core/build.rs Normal file
View file

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

View file

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

View file

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

View file

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

339
church-core/src/cache/mod.rs vendored Normal file
View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

69
church-core/src/config.rs Normal file
View file

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

62
church-core/src/error.rs Normal file
View file

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

12
church-core/src/ffi.rs Normal file
View file

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

24
church-core/src/lib.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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