Compare commits
No commits in common. "91a1bb7a54d08ff845c621c82bc84b6881644b27" and "7f711f7fbe8d51000da612f8f33ef784821555d8" have entirely different histories.
91a1bb7a54
...
7f711f7fbe
23
.gitignore
vendored
23
.gitignore
vendored
|
@ -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
|
Binary file not shown.
|
@ -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.
|
|
@ -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
|
||||
```
|
|
@ -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)
|
|
@ -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"
|
104
CHANGES.md
104
CHANGES.md
|
@ -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
123
CLAUDE.md
|
@ -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
|
113
NEXT-STEPS.md
113
NEXT-STEPS.md
|
@ -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
123
README.md
|
@ -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
24
astro-church-website/.gitignore
vendored
Normal 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/
|
|
@ -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]
|
|
@ -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:**
|
43
astro-church-website/README.md
Normal file
43
astro-church-website/README.md
Normal 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).
|
338
astro-church-website/index.cjs
Normal file
338
astro-church-website/index.cjs
Normal 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
29
astro-church-website/index.d.ts
vendored
Normal 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
|
|
@ -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
6752
astro-church-website/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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": [
|
1844
astro-church-website/public/admin/scripts/main.js
Normal file
1844
astro-church-website/public/admin/scripts/main.js
Normal file
File diff suppressed because it is too large
Load diff
1
astro-church-website/public/css/theme-dark.css
Normal file
1
astro-church-website/public/css/theme-dark.css
Normal file
File diff suppressed because one or more lines are too long
1
astro-church-website/public/css/theme-light.css
Normal file
1
astro-church-website/public/css/theme-light.css
Normal file
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 332 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
@ -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
|
|
@ -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;
|
484
astro-church-website/src/pages/admin/index.astro
Normal file
484
astro-church-website/src/pages/admin/index.astro
Normal 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>
|
|
@ -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 {
|
|
@ -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
113
church-core/Cargo.toml
Normal 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
6
church-core/build.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
fn main() {
|
||||
#[cfg(feature = "uniffi")]
|
||||
{
|
||||
uniffi::generate_scaffolding("src/church_core.udl").unwrap();
|
||||
}
|
||||
}
|
4
church-core/src/auth/mod.rs
Normal file
4
church-core/src/auth/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
// Authentication modules placeholder
|
||||
// This contains authentication implementations
|
||||
|
||||
pub use crate::models::AuthToken;
|
36
church-core/src/bin/test-date-submission.rs
Normal file
36
church-core/src/bin/test-date-submission.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use church_core::{
|
||||
client::{ChurchApiClient, events::submit_event},
|
||||
models::EventSubmission,
|
||||
config::ChurchCoreConfig,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config = ChurchCoreConfig::new();
|
||||
let client = ChurchApiClient::new(config).unwrap();
|
||||
|
||||
let submission = EventSubmission {
|
||||
title: "Test Event".to_string(),
|
||||
description: "Testing date submission".to_string(),
|
||||
start_time: "2025-06-28T23:00".to_string(), // The problematic format
|
||||
end_time: "2025-06-29T00:00".to_string(),
|
||||
location: "Test Location".to_string(),
|
||||
location_url: None,
|
||||
category: "Other".to_string(),
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
bulletin_week: None,
|
||||
submitter_email: "test@example.com".to_string(),
|
||||
};
|
||||
|
||||
println!("Testing date validation:");
|
||||
println!("Can parse start_time: {}", submission.parse_start_time().is_some());
|
||||
println!("Can parse end_time: {}", submission.parse_end_time().is_some());
|
||||
println!("Validation passes: {}", submission.validate_times());
|
||||
|
||||
println!("\nAttempting to submit event...");
|
||||
match submit_event(&client, submission).await {
|
||||
Ok(id) => println!("✅ Success! Event ID: {}", id),
|
||||
Err(e) => println!("❌ Error: {}", e),
|
||||
}
|
||||
}
|
94
church-core/src/bin/test.rs
Normal file
94
church-core/src/bin/test.rs
Normal 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
339
church-core/src/cache/mod.rs
vendored
Normal 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
|
78
church-core/src/church_core.udl
Normal file
78
church-core/src/church_core.udl
Normal 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);
|
||||
};
|
109
church-core/src/client/admin.rs
Normal file
109
church-core/src/client/admin.rs
Normal file
|
@ -0,0 +1,109 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{
|
||||
NewBulletin, BulletinUpdate,
|
||||
NewEvent, EventUpdate, PendingEvent,
|
||||
User, Schedule, NewSchedule, ScheduleUpdate,
|
||||
ApiVersion,
|
||||
},
|
||||
};
|
||||
|
||||
// Admin Bulletin Management
|
||||
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
|
||||
client.post_api_with_version("/admin/bulletins", &bulletin, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub async fn update_bulletin(client: &ChurchApiClient, id: &str, update: BulletinUpdate) -> Result<()> {
|
||||
let path = format!("/admin/bulletins/{}", id);
|
||||
client.put_api(&path, &update).await
|
||||
}
|
||||
|
||||
pub async fn delete_bulletin(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/bulletins/{}", id);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
// Admin Event Management
|
||||
pub async fn create_admin_event(client: &ChurchApiClient, event: NewEvent) -> Result<String> {
|
||||
client.post_api_with_version("/admin/events", &event, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_event(client: &ChurchApiClient, id: &str, update: EventUpdate) -> Result<()> {
|
||||
let path = format!("/admin/events/{}", id);
|
||||
client.put_api(&path, &update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/{}", id);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
// Admin Pending Events Management
|
||||
pub async fn get_pending_events(client: &ChurchApiClient) -> Result<Vec<PendingEvent>> {
|
||||
client.get_api("/admin/events/pending").await
|
||||
}
|
||||
|
||||
pub async fn approve_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/pending/{}/approve", id);
|
||||
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Failed to approve pending event".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reject_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/pending/{}/reject", id);
|
||||
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Failed to reject pending event".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/pending/{}", id);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
// Admin User Management
|
||||
pub async fn get_users(client: &ChurchApiClient) -> Result<Vec<User>> {
|
||||
client.get_api("/admin/users").await
|
||||
}
|
||||
|
||||
// Admin Schedule Management
|
||||
pub async fn create_schedule(client: &ChurchApiClient, schedule: NewSchedule) -> Result<String> {
|
||||
client.post_api("/admin/schedule", &schedule).await
|
||||
}
|
||||
|
||||
pub async fn update_schedule(client: &ChurchApiClient, date: &str, update: ScheduleUpdate) -> Result<()> {
|
||||
let path = format!("/admin/schedule/{}", date);
|
||||
client.put_api(&path, &update).await
|
||||
}
|
||||
|
||||
pub async fn delete_schedule(client: &ChurchApiClient, date: &str) -> Result<()> {
|
||||
let path = format!("/admin/schedule/{}", date);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_all_schedules(client: &ChurchApiClient) -> Result<Vec<Schedule>> {
|
||||
client.get_api("/admin/schedule").await
|
||||
}
|
||||
|
||||
// Admin Config Management
|
||||
pub async fn get_admin_config(client: &ChurchApiClient) -> Result<crate::models::ChurchConfig> {
|
||||
client.get_api("/admin/config").await
|
||||
}
|
169
church-core/src/client/bible.rs
Normal file
169
church-core/src/client/bible.rs
Normal file
|
@ -0,0 +1,169 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{BibleVerse, VerseOfTheDay, VerseCategory, PaginationParams, ApiListResponse, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn get_random_verse(client: &ChurchApiClient) -> Result<BibleVerse> {
|
||||
// The response format is {success: bool, data: Verse}
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct VerseResponse {
|
||||
success: bool,
|
||||
data: ApiVerse,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct ApiVerse {
|
||||
id: String,
|
||||
reference: String,
|
||||
text: String,
|
||||
#[serde(rename = "is_active")]
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
let url = client.build_url("/bible_verses/random");
|
||||
let raw_response = client.client.get(&url).send().await?;
|
||||
let response_text = raw_response.text().await?;
|
||||
let response: VerseResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
|
||||
|
||||
if response.success {
|
||||
Ok(BibleVerse::new(response.data.text, response.data.reference))
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_verse_of_the_day(client: &ChurchApiClient) -> Result<VerseOfTheDay> {
|
||||
client.get_api("/bible/verse-of-the-day").await
|
||||
}
|
||||
|
||||
pub async fn get_verse_by_reference(client: &ChurchApiClient, reference: &str) -> Result<Option<BibleVerse>> {
|
||||
let path = format!("/bible/verse?reference={}", urlencoding::encode(reference));
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(verse) => Ok(Some(verse)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_verses_by_category(client: &ChurchApiClient, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
let mut path = format!("/bible/category/{}", category.display_name().to_lowercase());
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list(&path).await?;
|
||||
Ok(response.data.items)
|
||||
}
|
||||
|
||||
pub async fn search_verses(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("&limit={}", limit));
|
||||
}
|
||||
|
||||
// The bible_verses/search endpoint returns a custom format with additional fields
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct ApiBibleVerse {
|
||||
id: String,
|
||||
reference: String,
|
||||
text: String,
|
||||
is_active: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct BibleSearchResponse {
|
||||
success: bool,
|
||||
data: Vec<ApiBibleVerse>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
let url = client.build_url(&path);
|
||||
let raw_response = client.client.get(&url).send().await?;
|
||||
let response_text = raw_response.text().await?;
|
||||
let response: BibleSearchResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
|
||||
if response.success {
|
||||
// Convert ApiBibleVerse to BibleVerse
|
||||
let verses = response.data.into_iter()
|
||||
.map(|api_verse| BibleVerse::new(api_verse.text, api_verse.reference))
|
||||
.collect();
|
||||
Ok(verses)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_random_verse_v2(client: &ChurchApiClient) -> Result<BibleVerse> {
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct VerseResponse {
|
||||
success: bool,
|
||||
data: ApiVerse,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct ApiVerse {
|
||||
id: String,
|
||||
reference: String,
|
||||
text: String,
|
||||
#[serde(rename = "is_active")]
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
let url = client.build_url_with_version("/bible_verses/random", ApiVersion::V2);
|
||||
let raw_response = client.client.get(&url).send().await?;
|
||||
let response_text = raw_response.text().await?;
|
||||
let response: VerseResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
|
||||
|
||||
if response.success {
|
||||
Ok(BibleVerse::new(response.data.text, response.data.reference))
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bible_verses_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
|
||||
let mut path = "/bible_verses".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn search_verses_v2(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("&limit={}", limit));
|
||||
}
|
||||
|
||||
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list_with_version(&path, ApiVersion::V2).await?;
|
||||
Ok(response.data.items)
|
||||
}
|
101
church-core/src/client/bulletins.rs
Normal file
101
church-core/src/client/bulletins.rs
Normal file
|
@ -0,0 +1,101 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{Bulletin, NewBulletin, PaginationParams, ApiListResponse, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn get_bulletins(client: &ChurchApiClient, active_only: bool) -> Result<Vec<Bulletin>> {
|
||||
let path = if active_only {
|
||||
"/bulletins?active=true"
|
||||
} else {
|
||||
"/bulletins"
|
||||
};
|
||||
|
||||
let response: ApiListResponse<Bulletin> = client.get_api_list(path).await?;
|
||||
Ok(response.data.items)
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api("/bulletins/current").await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bulletin(client: &ChurchApiClient, id: &str) -> Result<Option<Bulletin>> {
|
||||
let path = format!("/bulletins/{}", id);
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
|
||||
client.post_api("/bulletins", &bulletin).await
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api("/bulletins/next").await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_bulletins_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
|
||||
let mut path = "/bulletins".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
let url = client.build_url_with_version(&path, ApiVersion::V2);
|
||||
let response: ApiListResponse<Bulletin> = client.get(&url).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api_with_version("/bulletins/current", ApiVersion::V2).await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api_with_version("/bulletins/next", ApiVersion::V2).await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
50
church-core/src/client/config.rs
Normal file
50
church-core/src/client/config.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{ChurchConfig, Schedule, ConferenceData, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn get_config(client: &ChurchApiClient) -> Result<ChurchConfig> {
|
||||
client.get("/config").await
|
||||
}
|
||||
|
||||
pub async fn get_config_by_id(client: &ChurchApiClient, record_id: &str) -> Result<ChurchConfig> {
|
||||
let path = format!("/config/records/{}", record_id);
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn update_config(client: &ChurchApiClient, config: ChurchConfig) -> Result<()> {
|
||||
client.put_api("/config", &config).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_config_v2(client: &ChurchApiClient) -> Result<ChurchConfig> {
|
||||
client.get_api_with_version("/config", ApiVersion::V2).await
|
||||
}
|
||||
|
||||
// Schedule endpoints
|
||||
pub async fn get_schedule(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
|
||||
let path = if let Some(date) = date {
|
||||
format!("/schedule?date={}", date)
|
||||
} else {
|
||||
"/schedule".to_string()
|
||||
};
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_schedule_v2(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
|
||||
let path = if let Some(date) = date {
|
||||
format!("/schedule?date={}", date)
|
||||
} else {
|
||||
"/schedule".to_string()
|
||||
};
|
||||
client.get_api_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data(client: &ChurchApiClient) -> Result<ConferenceData> {
|
||||
client.get_api("/conference-data").await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data_v2(client: &ChurchApiClient) -> Result<ConferenceData> {
|
||||
client.get_api_with_version("/conference-data", ApiVersion::V2).await
|
||||
}
|
125
church-core/src/client/contact.rs
Normal file
125
church-core/src/client/contact.rs
Normal 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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query_params.push(("filter", filter.clone()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_contact_submission(client: &ChurchApiClient, id: &str) -> Result<Option<ContactSubmission>> {
|
||||
let path = format!("/contact/submissions/{}", id);
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(submission) => Ok(Some(submission)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_contact_submission(
|
||||
client: &ChurchApiClient,
|
||||
id: &str,
|
||||
status: ContactStatus,
|
||||
response: Option<String>
|
||||
) -> Result<()> {
|
||||
let path = format!("/contact/submissions/{}", id);
|
||||
|
||||
let update_data = serde_json::json!({
|
||||
"status": status,
|
||||
"response": response
|
||||
});
|
||||
|
||||
client.put_api(&path, &update_data).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn submit_contact_form_v2(client: &ChurchApiClient, form: ContactForm) -> Result<String> {
|
||||
let mut payload = serde_json::json!({
|
||||
"name": form.name,
|
||||
"email": form.email,
|
||||
"subject": form.subject,
|
||||
"message": form.message
|
||||
});
|
||||
|
||||
// Add phone field if provided
|
||||
if let Some(phone) = &form.phone {
|
||||
if !phone.trim().is_empty() {
|
||||
payload["phone"] = serde_json::json!(phone);
|
||||
}
|
||||
}
|
||||
|
||||
let url = client.build_url_with_version("/contact", ApiVersion::V2);
|
||||
|
||||
let response = client.client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok("Contact form submitted successfully".to_string())
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(format!("Contact form submission failed with status: {}", response.status())))
|
||||
}
|
||||
}
|
192
church-core/src/client/events.rs
Normal file
192
church-core/src/client/events.rs
Normal 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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query_params.push(("filter", filter.clone()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_upcoming_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = "/events/upcoming".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_featured_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = "/events/featured".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_event_v2(client: &ChurchApiClient, id: &str) -> Result<Option<Event>> {
|
||||
let path = format!("/events/{}", id);
|
||||
|
||||
match client.get_api_with_version(&path, ApiVersion::V2).await {
|
||||
Ok(event) => Ok(Some(event)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_event(client: &ChurchApiClient, submission: EventSubmission) -> Result<String> {
|
||||
client.post_api("/events/submit", &submission).await
|
||||
}
|
402
church-core/src/client/http.rs
Normal file
402
church-core/src/client/http.rs
Normal file
|
@ -0,0 +1,402 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::{ChurchApiError, Result},
|
||||
models::{ApiResponse, ApiListResponse, ApiVersion},
|
||||
cache::CachedHttpResponse,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
impl ChurchApiClient {
|
||||
pub(crate) async fn get<T>(&self, path: &str) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
self.get_with_version(path, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
let cache_key = format!("GET:{}:{:?}", path, version);
|
||||
|
||||
// Check cache first
|
||||
if self.config.enable_offline_mode {
|
||||
if let Some(cached) = self.cache.get::<T>(&cache_key).await {
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
let url = self.build_url_with_version(path, version);
|
||||
let request = self.client.get(&url);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await?;
|
||||
return Err(crate::error::ChurchApiError::Api(format!("HTTP {}: {}", status, error_text)));
|
||||
}
|
||||
|
||||
let response_text = response.text().await?;
|
||||
|
||||
let data: T = serde_json::from_str(&response_text).map_err(|e| {
|
||||
crate::error::ChurchApiError::Json(e)
|
||||
})?;
|
||||
|
||||
// Cache the result
|
||||
if self.config.enable_offline_mode {
|
||||
self.cache.set(&cache_key, &data, self.config.cache_ttl).await;
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api<T>(&self, path: &str) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
self.get_api_with_version(path, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
let response: ApiResponse<T> = self.get_with_version(path, version).await?;
|
||||
|
||||
if response.success {
|
||||
response.data.ok_or_else(|| {
|
||||
ChurchApiError::Api("API returned success but no data".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api_list<T>(&self, path: &str) -> Result<ApiListResponse<T>>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
self.get_api_list_with_version(path, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api_list_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<ApiListResponse<T>>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
let response: ApiListResponse<T> = self.get_with_version(path, version).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn post<T, R>(&self, path: &str, data: &T) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
self.post_with_version(path, data, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn post_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let url = self.build_url_with_version(path, version);
|
||||
let request = self.client.post(&url).json(data);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
let result: R = response.json().await?;
|
||||
|
||||
// Invalidate related cache entries
|
||||
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) async fn post_api<T, R>(&self, path: &str, data: &T) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
self.post_api_with_version(path, data, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn post_api_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let response: ApiResponse<R> = self.post_with_version(path, data, version).await?;
|
||||
|
||||
if response.success {
|
||||
response.data.ok_or_else(|| {
|
||||
ChurchApiError::Api("API returned success but no data".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn put<T, R>(&self, path: &str, data: &T) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let url = self.build_url(path);
|
||||
let request = self.client.put(&url).json(data);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
let result: R = response.json().await?;
|
||||
|
||||
// Invalidate related cache entries
|
||||
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) async fn put_api<T>(&self, path: &str, data: &T) -> Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let response: ApiResponse<()> = self.put(path, data).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete(&self, path: &str) -> Result<()> {
|
||||
let url = self.build_url(path);
|
||||
let request = self.client.delete(&url);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ChurchApiError::Http(
|
||||
reqwest::Error::from(response.error_for_status().unwrap_err())
|
||||
));
|
||||
}
|
||||
|
||||
// Invalidate related cache entries
|
||||
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_api(&self, path: &str) -> Result<()> {
|
||||
let response: ApiResponse<()> = {
|
||||
let url = self.build_url(path);
|
||||
let request = self.client.delete(&url);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
response.json().await?
|
||||
};
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_with_retry(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
|
||||
let mut attempts = 0;
|
||||
let max_attempts = self.config.retry_attempts;
|
||||
|
||||
loop {
|
||||
attempts += 1;
|
||||
|
||||
// Clone the request for potential retry
|
||||
let cloned_request = request.try_clone()
|
||||
.ok_or_else(|| ChurchApiError::Internal("Failed to clone request".to_string()))?;
|
||||
|
||||
match cloned_request.send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(ChurchApiError::Auth("Unauthorized".to_string()));
|
||||
} else if status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Err(ChurchApiError::PermissionDenied);
|
||||
} else if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(ChurchApiError::NotFound);
|
||||
} else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
if attempts < max_attempts {
|
||||
// Exponential backoff for rate limiting
|
||||
let delay = std::time::Duration::from_millis(100 * 2_u64.pow(attempts - 1));
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
return Err(ChurchApiError::RateLimit);
|
||||
}
|
||||
} else if status.is_server_error() && attempts < max_attempts {
|
||||
// Retry on server errors
|
||||
let delay = std::time::Duration::from_millis(500 * attempts as u64);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
return Err(ChurchApiError::Http(
|
||||
reqwest::Error::from(response.error_for_status().unwrap_err())
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if attempts < max_attempts && (e.is_timeout() || e.is_connect()) {
|
||||
// Retry on timeout and connection errors
|
||||
let delay = std::time::Duration::from_millis(500 * attempts as u64);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
return Err(ChurchApiError::Http(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn invalidate_cache_prefix(&self, prefix: &str) {
|
||||
self.cache.invalidate_prefix(prefix).await;
|
||||
}
|
||||
|
||||
pub(crate) fn build_query_string(&self, params: &[(&str, &str)]) -> String {
|
||||
if params.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let query: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}={}", urlencoding::encode(key), urlencoding::encode(value)))
|
||||
.collect();
|
||||
|
||||
format!("?{}", query.join("&"))
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_file(&self, path: &str, file_data: Vec<u8>, filename: String, field_name: String) -> Result<String> {
|
||||
let url = self.build_url(path);
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(file_data)
|
||||
.file_name(filename)
|
||||
.mime_str("application/octet-stream")
|
||||
.map_err(|e| ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part(field_name, part);
|
||||
|
||||
let request = self.client.post(&url).multipart(form);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
let result: ApiResponse<String> = response.json().await?;
|
||||
|
||||
if result.success {
|
||||
result.data.ok_or_else(|| {
|
||||
ChurchApiError::Api("File upload succeeded but no URL returned".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
result.error
|
||||
.or(result.message)
|
||||
.unwrap_or_else(|| "File upload failed".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch an image with HTTP caching support
|
||||
pub async fn get_cached_image(&self, url: &str) -> Result<CachedHttpResponse> {
|
||||
// Check cache first
|
||||
if let Some(cached) = self.cache.get_http_response(url).await {
|
||||
println!("📸 Cache HIT for image: {}", url);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
println!("📸 Cache MISS for image: {}", url);
|
||||
|
||||
// Make HTTP request
|
||||
let request = self.client.get(url);
|
||||
let response = self.send_with_retry(request).await?;
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(ChurchApiError::Http(
|
||||
reqwest::Error::from(response.error_for_status().unwrap_err())
|
||||
));
|
||||
}
|
||||
|
||||
// Extract headers we care about
|
||||
let mut header_map = HashMap::new();
|
||||
for (name, value) in headers.iter() {
|
||||
if let Ok(value_str) = value.to_str() {
|
||||
header_map.insert(name.to_string(), value_str.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
// Get response body
|
||||
let data = response.bytes().await?.to_vec();
|
||||
|
||||
// Determine cache TTL based on content type
|
||||
let ttl = if content_type.starts_with("image/") {
|
||||
Duration::from_secs(24 * 60 * 60) // 24 hours for images
|
||||
} else {
|
||||
Duration::from_secs(5 * 60) // 5 minutes for other content
|
||||
};
|
||||
|
||||
// Create cached response
|
||||
let cached_response = CachedHttpResponse::new(
|
||||
data,
|
||||
content_type,
|
||||
header_map,
|
||||
status.as_u16(),
|
||||
ttl,
|
||||
);
|
||||
|
||||
// Store in cache
|
||||
self.cache.set_http_response(url, cached_response.clone()).await;
|
||||
|
||||
Ok(cached_response)
|
||||
}
|
||||
}
|
35
church-core/src/client/livestream.rs
Normal file
35
church-core/src/client/livestream.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamStatus {
|
||||
pub is_live: bool,
|
||||
pub last_connect_time: Option<DateTime<Utc>>,
|
||||
pub last_disconnect_time: Option<DateTime<Utc>>,
|
||||
pub stream_title: Option<String>,
|
||||
pub stream_url: Option<String>,
|
||||
pub viewer_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LiveStream {
|
||||
pub last_connect_time: Option<DateTime<Utc>>,
|
||||
pub last_disconnect_time: Option<DateTime<Utc>>,
|
||||
pub viewer_count: Option<u32>,
|
||||
pub stream_title: Option<String>,
|
||||
pub is_live: bool,
|
||||
}
|
||||
|
||||
/// Get current stream status from Owncast
|
||||
pub async fn get_stream_status(client: &ChurchApiClient) -> Result<StreamStatus> {
|
||||
client.get("/stream/status").await
|
||||
}
|
||||
|
||||
/// Get live stream info from Owncast
|
||||
pub async fn get_live_stream(client: &ChurchApiClient) -> Result<LiveStream> {
|
||||
client.get("/stream/live").await
|
||||
}
|
412
church-core/src/client/mod.rs
Normal file
412
church-core/src/client/mod.rs
Normal file
|
@ -0,0 +1,412 @@
|
|||
pub mod http;
|
||||
pub mod events;
|
||||
pub mod bulletins;
|
||||
pub mod config;
|
||||
pub mod contact;
|
||||
pub mod sermons;
|
||||
pub mod bible;
|
||||
pub mod admin;
|
||||
pub mod uploads;
|
||||
pub mod livestream;
|
||||
|
||||
use crate::{
|
||||
cache::MemoryCache,
|
||||
config::ChurchCoreConfig,
|
||||
error::Result,
|
||||
models::*,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct ChurchApiClient {
|
||||
pub(crate) client: reqwest::Client,
|
||||
pub(crate) config: ChurchCoreConfig,
|
||||
pub(crate) auth_token: Arc<RwLock<Option<AuthToken>>>,
|
||||
pub(crate) cache: Arc<MemoryCache>,
|
||||
}
|
||||
|
||||
impl ChurchApiClient {
|
||||
pub fn new(config: ChurchCoreConfig) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(config.timeout)
|
||||
.connect_timeout(config.connect_timeout)
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
||||
.user_agent(&config.user_agent)
|
||||
.build()?;
|
||||
|
||||
let cache = Arc::new(MemoryCache::new(config.max_cache_size));
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
config,
|
||||
auth_token: Arc::new(RwLock::new(None)),
|
||||
cache,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_cache(mut self, cache: Arc<MemoryCache>) -> Self {
|
||||
self.cache = cache;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn set_auth_token(&self, token: AuthToken) {
|
||||
let mut auth = self.auth_token.write().await;
|
||||
*auth = Some(token);
|
||||
}
|
||||
|
||||
pub async fn clear_auth_token(&self) {
|
||||
let mut auth = self.auth_token.write().await;
|
||||
*auth = None;
|
||||
}
|
||||
|
||||
pub async fn get_auth_token(&self) -> Option<AuthToken> {
|
||||
let auth = self.auth_token.read().await;
|
||||
auth.clone()
|
||||
}
|
||||
|
||||
pub async fn is_authenticated(&self) -> bool {
|
||||
if let Some(token) = self.get_auth_token().await {
|
||||
token.is_valid()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_url(&self, path: &str) -> String {
|
||||
self.build_url_with_version(path, crate::models::ApiVersion::V1)
|
||||
}
|
||||
|
||||
pub(crate) fn build_url_with_version(&self, path: &str, version: crate::models::ApiVersion) -> String {
|
||||
if path.starts_with("http") {
|
||||
path.to_string()
|
||||
} else {
|
||||
let base = self.config.api_base_url.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
let version_prefix = version.path_prefix();
|
||||
|
||||
if base.ends_with("/api") {
|
||||
format!("{}/{}{}", base, version_prefix, path)
|
||||
} else {
|
||||
format!("{}/api/{}{}", base, version_prefix, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn add_auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(token) = self.get_auth_token().await {
|
||||
if token.is_valid() {
|
||||
return builder.header("Authorization", format!("{} {}", token.token_type, token.token));
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
// Event operations
|
||||
pub async fn get_upcoming_events(&self, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
events::get_upcoming_events(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_events(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
|
||||
events::get_events(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_event(&self, id: &str) -> Result<Option<Event>> {
|
||||
events::get_event(self, id).await
|
||||
}
|
||||
|
||||
pub async fn create_event(&self, event: NewEvent) -> Result<String> {
|
||||
events::create_event(self, event).await
|
||||
}
|
||||
|
||||
pub async fn update_event(&self, id: &str, update: EventUpdate) -> Result<()> {
|
||||
events::update_event(self, id, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_event(&self, id: &str) -> Result<()> {
|
||||
events::delete_event(self, id).await
|
||||
}
|
||||
|
||||
// Bulletin operations
|
||||
pub async fn get_bulletins(&self, active_only: bool) -> Result<Vec<Bulletin>> {
|
||||
bulletins::get_bulletins(self, active_only).await
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_current_bulletin(self).await
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_next_bulletin(self).await
|
||||
}
|
||||
|
||||
pub async fn get_bulletin(&self, id: &str) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_bulletin(self, id).await
|
||||
}
|
||||
|
||||
pub async fn create_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
|
||||
bulletins::create_bulletin(self, bulletin).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_bulletins_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
|
||||
bulletins::get_bulletins_v2(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin_v2(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_current_bulletin_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin_v2(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_next_bulletin_v2(self).await
|
||||
}
|
||||
|
||||
// Configuration
|
||||
pub async fn get_config(&self) -> Result<ChurchConfig> {
|
||||
config::get_config(self).await
|
||||
}
|
||||
|
||||
pub async fn get_config_by_id(&self, record_id: &str) -> Result<ChurchConfig> {
|
||||
config::get_config_by_id(self, record_id).await
|
||||
}
|
||||
|
||||
pub async fn update_config(&self, config: ChurchConfig) -> Result<()> {
|
||||
config::update_config(self, config).await
|
||||
}
|
||||
|
||||
// Contact operations
|
||||
pub async fn submit_contact_form(&self, form: ContactForm) -> Result<String> {
|
||||
contact::submit_contact_form(self, form).await
|
||||
}
|
||||
|
||||
pub async fn get_contact_submissions(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<ContactSubmission>> {
|
||||
contact::get_contact_submissions(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_contact_submission(&self, id: &str) -> Result<Option<ContactSubmission>> {
|
||||
contact::get_contact_submission(self, id).await
|
||||
}
|
||||
|
||||
pub async fn update_contact_submission(&self, id: &str, status: ContactStatus, response: Option<String>) -> Result<()> {
|
||||
contact::update_contact_submission(self, id, status, response).await
|
||||
}
|
||||
|
||||
// Sermon operations
|
||||
pub async fn get_sermons(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
|
||||
sermons::get_sermons(self, params).await
|
||||
}
|
||||
|
||||
pub async fn search_sermons(&self, search: SermonSearch, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
|
||||
sermons::search_sermons(self, search, params).await
|
||||
}
|
||||
|
||||
pub async fn get_sermon(&self, id: &str) -> Result<Option<Sermon>> {
|
||||
sermons::get_sermon(self, id).await
|
||||
}
|
||||
|
||||
pub async fn get_featured_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
sermons::get_featured_sermons(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_recent_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
sermons::get_recent_sermons(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn create_sermon(&self, sermon: NewSermon) -> Result<String> {
|
||||
sermons::create_sermon(self, sermon).await
|
||||
}
|
||||
|
||||
// Bible verse operations
|
||||
pub async fn get_random_verse(&self) -> Result<BibleVerse> {
|
||||
bible::get_random_verse(self).await
|
||||
}
|
||||
|
||||
pub async fn get_verse_of_the_day(&self) -> Result<VerseOfTheDay> {
|
||||
bible::get_verse_of_the_day(self).await
|
||||
}
|
||||
|
||||
pub async fn get_verse_by_reference(&self, reference: &str) -> Result<Option<BibleVerse>> {
|
||||
bible::get_verse_by_reference(self, reference).await
|
||||
}
|
||||
|
||||
pub async fn get_verses_by_category(&self, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
bible::get_verses_by_category(self, category, limit).await
|
||||
}
|
||||
|
||||
pub async fn search_verses(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
bible::search_verses(self, query, limit).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
|
||||
// Events V2
|
||||
pub async fn get_events_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
|
||||
events::get_events_v2(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_upcoming_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
events::get_upcoming_events_v2(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_featured_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
events::get_featured_events_v2(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_event_v2(&self, id: &str) -> Result<Option<Event>> {
|
||||
events::get_event_v2(self, id).await
|
||||
}
|
||||
|
||||
pub async fn submit_event(&self, submission: EventSubmission) -> Result<String> {
|
||||
events::submit_event(self, submission).await
|
||||
}
|
||||
|
||||
// Bible V2
|
||||
pub async fn get_random_verse_v2(&self) -> Result<BibleVerse> {
|
||||
bible::get_random_verse_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_bible_verses_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
|
||||
bible::get_bible_verses_v2(self, params).await
|
||||
}
|
||||
|
||||
pub async fn search_verses_v2(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
bible::search_verses_v2(self, query, limit).await
|
||||
}
|
||||
|
||||
// Contact V2
|
||||
pub async fn submit_contact_form_v2(&self, form: ContactForm) -> Result<String> {
|
||||
contact::submit_contact_form_v2(self, form).await
|
||||
}
|
||||
|
||||
// Config and Schedule V2
|
||||
pub async fn get_config_v2(&self) -> Result<ChurchConfig> {
|
||||
config::get_config_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_schedule(&self, date: Option<&str>) -> Result<Schedule> {
|
||||
config::get_schedule(self, date).await
|
||||
}
|
||||
|
||||
pub async fn get_schedule_v2(&self, date: Option<&str>) -> Result<Schedule> {
|
||||
config::get_schedule_v2(self, date).await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data(&self) -> Result<ConferenceData> {
|
||||
config::get_conference_data(self).await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data_v2(&self) -> Result<ConferenceData> {
|
||||
config::get_conference_data_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_livestreams(&self) -> Result<Vec<Sermon>> {
|
||||
sermons::get_livestreams(self).await
|
||||
}
|
||||
|
||||
// Owncast Live Streaming
|
||||
pub async fn get_stream_status(&self) -> Result<livestream::StreamStatus> {
|
||||
livestream::get_stream_status(self).await
|
||||
}
|
||||
|
||||
pub async fn get_live_stream(&self) -> Result<livestream::LiveStream> {
|
||||
livestream::get_live_stream(self).await
|
||||
}
|
||||
|
||||
// Admin operations
|
||||
|
||||
// Admin Bulletins
|
||||
pub async fn create_admin_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
|
||||
admin::create_bulletin(self, bulletin).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_bulletin(&self, id: &str, update: BulletinUpdate) -> Result<()> {
|
||||
admin::update_bulletin(self, id, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_bulletin(&self, id: &str) -> Result<()> {
|
||||
admin::delete_bulletin(self, id).await
|
||||
}
|
||||
|
||||
// Admin Events
|
||||
pub async fn create_admin_event(&self, event: NewEvent) -> Result<String> {
|
||||
admin::create_admin_event(self, event).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_event(&self, id: &str, update: EventUpdate) -> Result<()> {
|
||||
admin::update_admin_event(self, id, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_event(&self, id: &str) -> Result<()> {
|
||||
admin::delete_admin_event(self, id).await
|
||||
}
|
||||
|
||||
// Admin Pending Events
|
||||
pub async fn get_pending_events(&self) -> Result<Vec<PendingEvent>> {
|
||||
admin::get_pending_events(self).await
|
||||
}
|
||||
|
||||
pub async fn approve_pending_event(&self, id: &str) -> Result<()> {
|
||||
admin::approve_pending_event(self, id).await
|
||||
}
|
||||
|
||||
pub async fn reject_pending_event(&self, id: &str) -> Result<()> {
|
||||
admin::reject_pending_event(self, id).await
|
||||
}
|
||||
|
||||
pub async fn delete_pending_event(&self, id: &str) -> Result<()> {
|
||||
admin::delete_pending_event(self, id).await
|
||||
}
|
||||
|
||||
// Admin Users
|
||||
pub async fn get_admin_users(&self) -> Result<Vec<User>> {
|
||||
admin::get_users(self).await
|
||||
}
|
||||
|
||||
// Admin Schedule
|
||||
pub async fn create_admin_schedule(&self, schedule: NewSchedule) -> Result<String> {
|
||||
admin::create_schedule(self, schedule).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_schedule(&self, date: &str, update: ScheduleUpdate) -> Result<()> {
|
||||
admin::update_schedule(self, date, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_schedule(&self, date: &str) -> Result<()> {
|
||||
admin::delete_schedule(self, date).await
|
||||
}
|
||||
|
||||
pub async fn get_all_admin_schedules(&self) -> Result<Vec<Schedule>> {
|
||||
admin::get_all_schedules(self).await
|
||||
}
|
||||
|
||||
pub async fn get_admin_config(&self) -> Result<ChurchConfig> {
|
||||
admin::get_admin_config(self).await
|
||||
}
|
||||
|
||||
// File Upload operations
|
||||
pub async fn upload_bulletin_pdf(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
|
||||
uploads::upload_bulletin_pdf(self, bulletin_id, file_data, filename).await
|
||||
}
|
||||
|
||||
pub async fn upload_bulletin_cover(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
|
||||
uploads::upload_bulletin_cover(self, bulletin_id, file_data, filename).await
|
||||
}
|
||||
|
||||
pub async fn upload_event_image(&self, event_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
|
||||
uploads::upload_event_image(self, event_id, file_data, filename).await
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
pub async fn health_check(&self) -> Result<bool> {
|
||||
let url = self.build_url("/health");
|
||||
let response = self.client.get(&url).send().await?;
|
||||
Ok(response.status().is_success())
|
||||
}
|
||||
|
||||
pub async fn clear_cache(&self) {
|
||||
self.cache.clear().await;
|
||||
}
|
||||
|
||||
pub async fn get_cache_stats(&self) -> (usize, usize) {
|
||||
(self.cache.len().await, self.config.max_cache_size)
|
||||
}
|
||||
}
|
237
church-core/src/client/sermons.rs
Normal file
237
church-core/src/client/sermons.rs
Normal 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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query_params.push(("filter", filter.clone()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list(&path).await
|
||||
}
|
||||
|
||||
pub async fn search_sermons(
|
||||
client: &ChurchApiClient,
|
||||
search: SermonSearch,
|
||||
params: Option<PaginationParams>
|
||||
) -> Result<ApiListResponse<Sermon>> {
|
||||
let mut path = "/sermons/search".to_string();
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(query) = &search.query {
|
||||
query_params.push(("q", query.clone()));
|
||||
}
|
||||
|
||||
if let Some(speaker) = &search.speaker {
|
||||
query_params.push(("speaker", speaker.clone()));
|
||||
}
|
||||
|
||||
if let Some(category) = &search.category {
|
||||
query_params.push(("category", format!("{:?}", category).to_lowercase()));
|
||||
}
|
||||
|
||||
if let Some(series) = &search.series {
|
||||
query_params.push(("series", series.clone()));
|
||||
}
|
||||
|
||||
if let Some(featured_only) = search.featured_only {
|
||||
if featured_only {
|
||||
query_params.push(("featured", "true".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(has_video) = search.has_video {
|
||||
if has_video {
|
||||
query_params.push(("has_video", "true".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(has_audio) = search.has_audio {
|
||||
if has_audio {
|
||||
query_params.push(("has_audio", "true".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(params) = params {
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
|
||||
client.get_api_list(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_sermon(client: &ChurchApiClient, id: &str) -> Result<Option<Sermon>> {
|
||||
let path = format!("/sermons/{}", id);
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(sermon) => Ok(Some(sermon)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_featured_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
let mut path = "/sermons/featured".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
// Helper function to convert seconds to human readable duration
|
||||
fn format_duration_seconds(seconds: u32) -> String {
|
||||
let hours = seconds / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let remaining_seconds = seconds % 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{}:{:02}:{:02}", hours, minutes, remaining_seconds)
|
||||
} else {
|
||||
format!("{}:{:02}", minutes, remaining_seconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared function to convert API sermon/livestream data to Sermon model
|
||||
fn convert_api_sermon_to_sermon(api_sermon: ApiSermon, category: crate::models::sermon::SermonCategory) -> Sermon {
|
||||
// Parse date string to DateTime if available
|
||||
let date = if let Some(date_str) = &api_sermon.date {
|
||||
chrono::DateTime::parse_from_str(&format!("{} 00:00:00 +0000", date_str), "%Y-%m-%d %H:%M:%S %z")
|
||||
.unwrap_or_else(|_| chrono::Utc::now().into())
|
||||
.with_timezone(&chrono::Utc)
|
||||
} else {
|
||||
chrono::Utc::now()
|
||||
};
|
||||
|
||||
// Duration is already in string format from the API, so use it directly
|
||||
let duration_string = Some(api_sermon.duration.clone());
|
||||
|
||||
// Generate optimal streaming URL for the device
|
||||
let media_url = if !api_sermon.id.is_empty() {
|
||||
let base_url = "https://api.rockvilletollandsda.church"; // TODO: Get from config
|
||||
let streaming_url = DeviceCapabilities::get_optimal_streaming_url(base_url, &api_sermon.id);
|
||||
Some(streaming_url.url)
|
||||
} else {
|
||||
api_sermon.video_url.clone()
|
||||
};
|
||||
|
||||
Sermon {
|
||||
id: api_sermon.id.clone(),
|
||||
title: api_sermon.title,
|
||||
speaker: api_sermon.speaker.unwrap_or("Unknown".to_string()),
|
||||
description: api_sermon.description.unwrap_or_default(),
|
||||
date,
|
||||
scripture_reference: api_sermon.scripture_reading.unwrap_or_default(),
|
||||
series: None,
|
||||
duration_string,
|
||||
media_url,
|
||||
audio_url: api_sermon.audio_url,
|
||||
video_url: api_sermon.video_url,
|
||||
transcript: None,
|
||||
thumbnail: api_sermon.thumbnail,
|
||||
tags: None,
|
||||
category,
|
||||
is_featured: false,
|
||||
view_count: 0,
|
||||
download_count: 0,
|
||||
created_at: date,
|
||||
updated_at: date,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_recent_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
let mut path = "/sermons".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
// The new API returns a wrapper with "sermons" array
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct SermonsResponse {
|
||||
success: bool,
|
||||
data: Vec<ApiSermon>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
let response: SermonsResponse = client.get(&path).await?;
|
||||
|
||||
// Convert using shared logic
|
||||
let sermons = response.data.into_iter()
|
||||
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::Regular))
|
||||
.collect();
|
||||
|
||||
Ok(sermons)
|
||||
}
|
||||
|
||||
pub async fn create_sermon(client: &ChurchApiClient, sermon: NewSermon) -> Result<String> {
|
||||
client.post_api("/sermons", &sermon).await
|
||||
}
|
||||
|
||||
// Livestreams endpoint - reuses ApiSermon since format is identical
|
||||
|
||||
pub async fn get_livestreams(client: &ChurchApiClient) -> Result<Vec<Sermon>> {
|
||||
// Use the new API endpoint for livestreams
|
||||
let path = "/livestreams";
|
||||
|
||||
// The new API returns a wrapper with "data" array (same format as sermons endpoint)
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct LivestreamsResponse {
|
||||
success: bool,
|
||||
data: Vec<ApiSermon>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
let response: LivestreamsResponse = client.get(path).await?;
|
||||
|
||||
// Convert using shared logic - same as regular sermons but different category
|
||||
let sermons = response.data.into_iter()
|
||||
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::LivestreamArchive))
|
||||
.collect();
|
||||
|
||||
Ok(sermons)
|
||||
}
|
119
church-core/src/client/uploads.rs
Normal file
119
church-core/src/client/uploads.rs
Normal 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
69
church-core/src/config.rs
Normal 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
62
church-core/src/error.rs
Normal 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
12
church-core/src/ffi.rs
Normal 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
24
church-core/src/lib.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
pub mod client;
|
||||
pub mod models;
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod utils;
|
||||
pub mod error;
|
||||
pub mod config;
|
||||
pub use client::ChurchApiClient;
|
||||
pub use config::ChurchCoreConfig;
|
||||
pub use error::{ChurchApiError, Result};
|
||||
pub use models::*;
|
||||
pub use cache::*;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
||||
#[cfg(feature = "uniffi")]
|
||||
pub mod uniffi_wrapper;
|
||||
|
||||
#[cfg(feature = "uniffi")]
|
||||
pub use uniffi_wrapper::*;
|
||||
|
||||
#[cfg(feature = "uniffi")]
|
||||
uniffi::include_scaffolding!("church_core");
|
92
church-core/src/models/admin.rs
Normal file
92
church-core/src/models/admin.rs
Normal file
|
@ -0,0 +1,92 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// User information for admin user management
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: AdminUserRole,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum AdminUserRole {
|
||||
#[serde(rename = "admin")]
|
||||
Admin,
|
||||
#[serde(rename = "moderator")]
|
||||
Moderator,
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
}
|
||||
|
||||
/// Schedule data
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Schedule {
|
||||
pub date: String, // YYYY-MM-DD format
|
||||
pub sabbath_school: Option<String>,
|
||||
pub divine_worship: Option<String>,
|
||||
pub scripture_reading: Option<String>,
|
||||
pub sunset: Option<String>,
|
||||
pub special_notes: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Conference data
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ConferenceData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub website: Option<String>,
|
||||
pub contact_info: Option<String>,
|
||||
pub leadership: Option<Vec<ConferenceLeader>>,
|
||||
pub announcements: Option<Vec<String>>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ConferenceLeader {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
}
|
||||
|
||||
/// New schedule creation
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct NewSchedule {
|
||||
pub date: String, // YYYY-MM-DD format
|
||||
pub sabbath_school: Option<String>,
|
||||
pub divine_worship: Option<String>,
|
||||
pub scripture_reading: Option<String>,
|
||||
pub sunset: Option<String>,
|
||||
pub special_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Schedule update
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ScheduleUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sabbath_school: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub divine_worship: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scripture_reading: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sunset: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub special_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// File upload response
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UploadResponse {
|
||||
pub file_path: String,
|
||||
pub pdf_path: Option<String>, // Full URL to the uploaded file
|
||||
pub message: String,
|
||||
}
|
276
church-core/src/models/auth.rs
Normal file
276
church-core/src/models/auth.rs
Normal file
|
@ -0,0 +1,276 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AuthToken {
|
||||
pub token: String,
|
||||
pub token_type: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub user_id: Option<String>,
|
||||
pub user_name: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LoginRequest {
|
||||
pub identity: String, // email or username
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: AuthUser,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub username: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub verified: bool,
|
||||
pub role: UserRole,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RefreshTokenRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RefreshTokenResponse {
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PasswordResetRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PasswordResetConfirm {
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub name: String,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EmailVerificationRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum UserRole {
|
||||
#[serde(rename = "admin")]
|
||||
Admin,
|
||||
#[serde(rename = "pastor")]
|
||||
Pastor,
|
||||
#[serde(rename = "elder")]
|
||||
Elder,
|
||||
#[serde(rename = "deacon")]
|
||||
Deacon,
|
||||
#[serde(rename = "ministry_leader")]
|
||||
MinistryLeader,
|
||||
#[serde(rename = "member")]
|
||||
Member,
|
||||
#[serde(rename = "visitor")]
|
||||
Visitor,
|
||||
#[serde(rename = "guest")]
|
||||
Guest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PocketBaseAuthResponse {
|
||||
pub token: String,
|
||||
pub record: PocketBaseUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PocketBaseUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub username: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub verified: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl AuthToken {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.is_expired() && !self.token.is_empty()
|
||||
}
|
||||
|
||||
pub fn expires_in_seconds(&self) -> i64 {
|
||||
(self.expires_at - Utc::now()).num_seconds().max(0)
|
||||
}
|
||||
|
||||
pub fn expires_in_minutes(&self) -> i64 {
|
||||
(self.expires_at - Utc::now()).num_minutes().max(0)
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, permission: &str) -> bool {
|
||||
self.permissions.contains(&permission.to_string())
|
||||
}
|
||||
|
||||
pub fn has_any_permission(&self, permissions: &[&str]) -> bool {
|
||||
permissions.iter().any(|p| self.has_permission(p))
|
||||
}
|
||||
|
||||
pub fn has_all_permissions(&self, permissions: &[&str]) -> bool {
|
||||
permissions.iter().all(|p| self.has_permission(p))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
matches!(self.role, UserRole::Admin)
|
||||
}
|
||||
|
||||
pub fn is_pastor(&self) -> bool {
|
||||
matches!(self.role, UserRole::Pastor)
|
||||
}
|
||||
|
||||
pub fn is_leadership(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_member(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin
|
||||
| UserRole::Pastor
|
||||
| UserRole::Elder
|
||||
| UserRole::Deacon
|
||||
| UserRole::MinistryLeader
|
||||
| UserRole::Member
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_edit_content(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::MinistryLeader
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_moderate(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
|
||||
)
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> String {
|
||||
if !self.name.is_empty() {
|
||||
self.name.clone()
|
||||
} else if let Some(username) = &self.username {
|
||||
username.clone()
|
||||
} else {
|
||||
self.email.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
UserRole::Admin => "Administrator",
|
||||
UserRole::Pastor => "Pastor",
|
||||
UserRole::Elder => "Elder",
|
||||
UserRole::Deacon => "Deacon",
|
||||
UserRole::MinistryLeader => "Ministry Leader",
|
||||
UserRole::Member => "Member",
|
||||
UserRole::Visitor => "Visitor",
|
||||
UserRole::Guest => "Guest",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn permissions(&self) -> Vec<&'static str> {
|
||||
match self {
|
||||
UserRole::Admin => vec![
|
||||
"admin.*",
|
||||
"events.*",
|
||||
"bulletins.*",
|
||||
"sermons.*",
|
||||
"contacts.*",
|
||||
"users.*",
|
||||
"config.*",
|
||||
],
|
||||
UserRole::Pastor => vec![
|
||||
"events.*",
|
||||
"bulletins.*",
|
||||
"sermons.*",
|
||||
"contacts.read",
|
||||
"contacts.respond",
|
||||
"users.read",
|
||||
],
|
||||
UserRole::Elder => vec![
|
||||
"events.read",
|
||||
"events.create",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
"contacts.read",
|
||||
"contacts.respond",
|
||||
],
|
||||
UserRole::Deacon => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
"contacts.read",
|
||||
],
|
||||
UserRole::MinistryLeader => vec![
|
||||
"events.read",
|
||||
"events.create",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
],
|
||||
UserRole::Member => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
],
|
||||
UserRole::Visitor => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
],
|
||||
UserRole::Guest => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
136
church-core/src/models/bible.rs
Normal file
136
church-core/src/models/bible.rs
Normal file
|
@ -0,0 +1,136 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BibleVerse {
|
||||
pub text: String,
|
||||
pub reference: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub book: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chapter: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub verse: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<VerseCategory>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VerseOfTheDay {
|
||||
pub verse: BibleVerse,
|
||||
pub date: chrono::NaiveDate,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commentary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum VerseCategory {
|
||||
#[serde(rename = "comfort")]
|
||||
Comfort,
|
||||
#[serde(rename = "hope")]
|
||||
Hope,
|
||||
#[serde(rename = "faith")]
|
||||
Faith,
|
||||
#[serde(rename = "love")]
|
||||
Love,
|
||||
#[serde(rename = "peace")]
|
||||
Peace,
|
||||
#[serde(rename = "strength")]
|
||||
Strength,
|
||||
#[serde(rename = "wisdom")]
|
||||
Wisdom,
|
||||
#[serde(rename = "guidance")]
|
||||
Guidance,
|
||||
#[serde(rename = "forgiveness")]
|
||||
Forgiveness,
|
||||
#[serde(rename = "salvation")]
|
||||
Salvation,
|
||||
#[serde(rename = "prayer")]
|
||||
Prayer,
|
||||
#[serde(rename = "praise")]
|
||||
Praise,
|
||||
#[serde(rename = "thanksgiving")]
|
||||
Thanksgiving,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl BibleVerse {
|
||||
pub fn new(text: String, reference: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
reference,
|
||||
version: None,
|
||||
book: None,
|
||||
chapter: None,
|
||||
verse: None,
|
||||
category: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_version(mut self, version: String) -> Self {
|
||||
self.version = Some(version);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_book(mut self, book: String) -> Self {
|
||||
self.book = Some(book);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_location(mut self, chapter: u32, verse: u32) -> Self {
|
||||
self.chapter = Some(chapter);
|
||||
self.verse = Some(verse);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: VerseCategory) -> Self {
|
||||
self.category = Some(category);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl VerseOfTheDay {
|
||||
pub fn new(verse: BibleVerse, date: chrono::NaiveDate) -> Self {
|
||||
Self {
|
||||
verse,
|
||||
date,
|
||||
commentary: None,
|
||||
theme: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_commentary(mut self, commentary: String) -> Self {
|
||||
self.commentary = Some(commentary);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_theme(mut self, theme: String) -> Self {
|
||||
self.theme = Some(theme);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl VerseCategory {
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
VerseCategory::Comfort => "Comfort",
|
||||
VerseCategory::Hope => "Hope",
|
||||
VerseCategory::Faith => "Faith",
|
||||
VerseCategory::Love => "Love",
|
||||
VerseCategory::Peace => "Peace",
|
||||
VerseCategory::Strength => "Strength",
|
||||
VerseCategory::Wisdom => "Wisdom",
|
||||
VerseCategory::Guidance => "Guidance",
|
||||
VerseCategory::Forgiveness => "Forgiveness",
|
||||
VerseCategory::Salvation => "Salvation",
|
||||
VerseCategory::Prayer => "Prayer",
|
||||
VerseCategory::Praise => "Praise",
|
||||
VerseCategory::Thanksgiving => "Thanksgiving",
|
||||
VerseCategory::Other => "Other",
|
||||
}
|
||||
}
|
||||
}
|
240
church-core/src/models/bulletin.rs
Normal file
240
church-core/src/models/bulletin.rs
Normal 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()
|
||||
}
|
||||
}
|
344
church-core/src/models/client_models.rs
Normal file
344
church-core/src/models/client_models.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
73
church-core/src/models/common.rs
Normal file
73
church-core/src/models/common.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiListResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: ApiListData<T>,
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiListData<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: u32,
|
||||
pub page: u32,
|
||||
pub per_page: u32,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PaginationParams {
|
||||
pub page: Option<u32>,
|
||||
pub per_page: Option<u32>,
|
||||
pub sort: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PaginationParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: Some(1),
|
||||
per_page: Some(50),
|
||||
sort: None,
|
||||
filter: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PaginationParams {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_page(mut self, page: u32) -> Self {
|
||||
self.page = Some(page);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_per_page(mut self, per_page: u32) -> Self {
|
||||
self.per_page = Some(per_page);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_sort(mut self, sort: impl Into<String>) -> Self {
|
||||
self.sort = Some(sort.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_filter(mut self, filter: impl Into<String>) -> Self {
|
||||
self.filter = Some(filter.into());
|
||||
self
|
||||
}
|
||||
}
|
253
church-core/src/models/config.rs
Normal file
253
church-core/src/models/config.rs
Normal file
|
@ -0,0 +1,253 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Coordinates {
|
||||
pub lat: f64,
|
||||
pub lng: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChurchConfig {
|
||||
pub church_name: Option<String>,
|
||||
pub church_address: Option<String>,
|
||||
pub po_box: Option<String>,
|
||||
pub contact_phone: Option<String>,
|
||||
pub contact_email: Option<String>,
|
||||
pub website_url: Option<String>,
|
||||
pub google_maps_url: Option<String>,
|
||||
pub facebook_url: Option<String>,
|
||||
pub youtube_url: Option<String>,
|
||||
pub instagram_url: Option<String>,
|
||||
pub about_text: Option<String>,
|
||||
pub mission_statement: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub brand_color: Option<String>,
|
||||
pub donation_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_times: Option<Vec<ServiceTime>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pastoral_staff: Option<Vec<StaffMember>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ministries: Option<Vec<Ministry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app_settings: Option<AppSettings>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emergency_contacts: Option<Vec<EmergencyContact>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub coordinates: Option<Coordinates>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ServiceTime {
|
||||
pub day: String,
|
||||
pub service: String,
|
||||
pub time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ServiceTimes {
|
||||
pub sabbath_school: Option<String>,
|
||||
pub divine_worship: Option<String>,
|
||||
pub prayer_meeting: Option<String>,
|
||||
pub youth_service: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub special_services: Option<Vec<SpecialService>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SpecialService {
|
||||
pub name: String,
|
||||
pub time: String,
|
||||
pub frequency: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StaffMember {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub photo: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bio: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub responsibilities: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Ministry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leader: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meeting_time: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meeting_location: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<String>,
|
||||
pub category: MinistryCategory,
|
||||
#[serde(default)]
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EmergencyContact {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub phone: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
pub priority: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub availability: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppSettings {
|
||||
pub enable_notifications: bool,
|
||||
pub enable_calendar_sync: bool,
|
||||
pub enable_offline_mode: bool,
|
||||
pub theme: AppTheme,
|
||||
pub default_language: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owncast_server: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bible_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hymnal_version: Option<String>,
|
||||
pub cache_duration_minutes: u32,
|
||||
pub auto_refresh_interval_minutes: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum MinistryCategory {
|
||||
#[serde(rename = "worship")]
|
||||
Worship,
|
||||
#[serde(rename = "education")]
|
||||
Education,
|
||||
#[serde(rename = "youth")]
|
||||
Youth,
|
||||
#[serde(rename = "children")]
|
||||
Children,
|
||||
#[serde(rename = "outreach")]
|
||||
Outreach,
|
||||
#[serde(rename = "health")]
|
||||
Health,
|
||||
#[serde(rename = "music")]
|
||||
Music,
|
||||
#[serde(rename = "fellowship")]
|
||||
Fellowship,
|
||||
#[serde(rename = "prayer")]
|
||||
Prayer,
|
||||
#[serde(rename = "stewardship")]
|
||||
Stewardship,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum AppTheme {
|
||||
#[serde(rename = "light")]
|
||||
Light,
|
||||
#[serde(rename = "dark")]
|
||||
Dark,
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_notifications: true,
|
||||
enable_calendar_sync: true,
|
||||
enable_offline_mode: true,
|
||||
theme: AppTheme::System,
|
||||
default_language: "en".to_string(),
|
||||
owncast_server: None,
|
||||
bible_version: Some("KJV".to_string()),
|
||||
hymnal_version: Some("1985".to_string()),
|
||||
cache_duration_minutes: 60,
|
||||
auto_refresh_interval_minutes: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChurchConfig {
|
||||
pub fn get_display_name(&self) -> String {
|
||||
self.church_name
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Church".to_string())
|
||||
}
|
||||
|
||||
pub fn has_social_media(&self) -> bool {
|
||||
self.facebook_url.is_some() || self.youtube_url.is_some() || self.instagram_url.is_some()
|
||||
}
|
||||
|
||||
pub fn get_contact_info(&self) -> Vec<(String, String)> {
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
if let Some(phone) = &self.contact_phone {
|
||||
contacts.push(("Phone".to_string(), phone.clone()));
|
||||
}
|
||||
|
||||
if let Some(email) = &self.contact_email {
|
||||
contacts.push(("Email".to_string(), email.clone()));
|
||||
}
|
||||
|
||||
if let Some(address) = &self.church_address {
|
||||
contacts.push(("Address".to_string(), address.clone()));
|
||||
}
|
||||
|
||||
if let Some(po_box) = &self.po_box {
|
||||
contacts.push(("PO Box".to_string(), po_box.clone()));
|
||||
}
|
||||
|
||||
contacts
|
||||
}
|
||||
|
||||
pub fn active_ministries(&self) -> Vec<&Ministry> {
|
||||
self.ministries
|
||||
.as_ref()
|
||||
.map(|ministries| {
|
||||
ministries
|
||||
.iter()
|
||||
.filter(|ministry| ministry.is_active)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn ministries_by_category(&self, category: MinistryCategory) -> Vec<&Ministry> {
|
||||
self.ministries
|
||||
.as_ref()
|
||||
.map(|ministries| {
|
||||
ministries
|
||||
.iter()
|
||||
.filter(|ministry| ministry.category == category && ministry.is_active)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn emergency_contacts_by_priority(&self) -> Vec<&EmergencyContact> {
|
||||
self.emergency_contacts
|
||||
.as_ref()
|
||||
.map(|contacts| {
|
||||
let mut sorted = contacts.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by_key(|contact| contact.priority);
|
||||
sorted
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
339
church-core/src/models/contact.rs
Normal file
339
church-core/src/models/contact.rs
Normal 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()
|
||||
}
|
||||
}
|
349
church-core/src/models/event.rs
Normal file
349
church-core/src/models/event.rs
Normal 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>,
|
||||
}
|
28
church-core/src/models/mod.rs
Normal file
28
church-core/src/models/mod.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
pub mod common;
|
||||
pub mod event;
|
||||
pub mod bulletin;
|
||||
pub mod config;
|
||||
pub mod contact;
|
||||
pub mod sermon;
|
||||
pub mod streaming;
|
||||
pub mod auth;
|
||||
pub mod bible;
|
||||
pub mod client_models;
|
||||
pub mod v2;
|
||||
pub mod admin;
|
||||
|
||||
pub use common::*;
|
||||
pub use event::*;
|
||||
pub use bulletin::*;
|
||||
pub use config::*;
|
||||
pub use contact::*;
|
||||
pub use sermon::*;
|
||||
pub use streaming::*;
|
||||
pub use auth::*;
|
||||
pub use bible::*;
|
||||
pub use client_models::*;
|
||||
pub use v2::*;
|
||||
pub use admin::*;
|
||||
|
||||
// Re-export livestream types from client module for convenience
|
||||
pub use crate::client::livestream::{StreamStatus, LiveStream};
|
376
church-core/src/models/sermon.rs
Normal file
376
church-core/src/models/sermon.rs
Normal file
|
@ -0,0 +1,376 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// API response structure for sermons from the external API
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiSermon {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub speaker: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub duration: String, // Duration as string like "1:13:01"
|
||||
pub description: Option<String>,
|
||||
pub audio_url: Option<String>,
|
||||
pub video_url: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub scripture_reading: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Sermon {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub speaker: String,
|
||||
pub description: String,
|
||||
pub date: DateTime<Utc>,
|
||||
pub scripture_reference: String,
|
||||
pub series: Option<String>,
|
||||
pub duration_string: Option<String>, // Raw duration from API (e.g., "2:34:49")
|
||||
pub media_url: Option<String>,
|
||||
pub audio_url: Option<String>,
|
||||
pub video_url: Option<String>,
|
||||
pub transcript: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub category: SermonCategory,
|
||||
pub is_featured: bool,
|
||||
pub view_count: u32,
|
||||
pub download_count: u32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct NewSermon {
|
||||
pub title: String,
|
||||
pub speaker: String,
|
||||
pub description: String,
|
||||
pub date: DateTime<Utc>,
|
||||
pub scripture_reference: String,
|
||||
pub series: Option<String>,
|
||||
pub duration_string: Option<String>,
|
||||
pub media_url: Option<String>,
|
||||
pub audio_url: Option<String>,
|
||||
pub video_url: Option<String>,
|
||||
pub transcript: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub category: SermonCategory,
|
||||
pub is_featured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonSeries {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub speaker: String,
|
||||
pub start_date: DateTime<Utc>,
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub sermons: Vec<Sermon>,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonNote {
|
||||
pub id: String,
|
||||
pub sermon_id: String,
|
||||
pub user_id: String,
|
||||
pub content: String,
|
||||
pub timestamp_seconds: Option<u32>,
|
||||
pub is_private: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonFeedback {
|
||||
pub id: String,
|
||||
pub sermon_id: String,
|
||||
pub user_name: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
pub rating: Option<u8>, // 1-5 stars
|
||||
pub comment: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum SermonCategory {
|
||||
#[serde(rename = "regular")]
|
||||
Regular,
|
||||
#[serde(rename = "evangelistic")]
|
||||
Evangelistic,
|
||||
#[serde(rename = "youth")]
|
||||
Youth,
|
||||
#[serde(rename = "children")]
|
||||
Children,
|
||||
#[serde(rename = "special")]
|
||||
Special,
|
||||
#[serde(rename = "prophecy")]
|
||||
Prophecy,
|
||||
#[serde(rename = "health")]
|
||||
Health,
|
||||
#[serde(rename = "stewardship")]
|
||||
Stewardship,
|
||||
#[serde(rename = "testimony")]
|
||||
Testimony,
|
||||
#[serde(rename = "holiday")]
|
||||
Holiday,
|
||||
#[serde(rename = "communion")]
|
||||
Communion,
|
||||
#[serde(rename = "baptism")]
|
||||
Baptism,
|
||||
#[serde(rename = "wedding")]
|
||||
Wedding,
|
||||
#[serde(rename = "funeral")]
|
||||
Funeral,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
#[serde(rename = "livestream_archive")]
|
||||
LivestreamArchive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonSearch {
|
||||
pub query: Option<String>,
|
||||
pub speaker: Option<String>,
|
||||
pub category: Option<SermonCategory>,
|
||||
pub series: Option<String>,
|
||||
pub date_from: Option<DateTime<Utc>>,
|
||||
pub date_to: Option<DateTime<Utc>>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub featured_only: Option<bool>,
|
||||
pub has_video: Option<bool>,
|
||||
pub has_audio: Option<bool>,
|
||||
pub has_transcript: Option<bool>,
|
||||
pub min_duration: Option<u32>,
|
||||
pub max_duration: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for SermonSearch {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: None,
|
||||
speaker: None,
|
||||
category: None,
|
||||
series: None,
|
||||
date_from: None,
|
||||
date_to: None,
|
||||
tags: None,
|
||||
featured_only: None,
|
||||
has_video: None,
|
||||
has_audio: None,
|
||||
has_transcript: None,
|
||||
min_duration: None,
|
||||
max_duration: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SermonSearch {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_query(mut self, query: String) -> Self {
|
||||
self.query = Some(query);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_speaker(mut self, speaker: String) -> Self {
|
||||
self.speaker = Some(speaker);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: SermonCategory) -> Self {
|
||||
self.category = Some(category);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_series(mut self, series: String) -> Self {
|
||||
self.series = Some(series);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_date_range(mut self, from: DateTime<Utc>, to: DateTime<Utc>) -> Self {
|
||||
self.date_from = Some(from);
|
||||
self.date_to = Some(to);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn featured_only(mut self) -> Self {
|
||||
self.featured_only = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_video(mut self) -> Self {
|
||||
self.has_video = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_audio(mut self) -> Self {
|
||||
self.has_audio = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_transcript(mut self) -> Self {
|
||||
self.has_transcript = Some(true);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sermon {
|
||||
pub fn duration_formatted(&self) -> String {
|
||||
self.duration_string.clone().unwrap_or_else(|| "Unknown".to_string())
|
||||
}
|
||||
|
||||
pub fn has_media(&self) -> bool {
|
||||
self.media_url.is_some() || self.audio_url.is_some() || self.video_url.is_some()
|
||||
}
|
||||
|
||||
pub fn has_video(&self) -> bool {
|
||||
self.video_url.is_some() || self.media_url.is_some()
|
||||
}
|
||||
|
||||
pub fn has_audio(&self) -> bool {
|
||||
self.audio_url.is_some() || self.media_url.is_some()
|
||||
}
|
||||
|
||||
pub fn has_transcript(&self) -> bool {
|
||||
self.transcript.is_some()
|
||||
}
|
||||
|
||||
pub fn is_recent(&self) -> bool {
|
||||
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
|
||||
self.date > thirty_days_ago
|
||||
}
|
||||
|
||||
pub fn is_popular(&self) -> bool {
|
||||
self.view_count > 100 || self.download_count > 50
|
||||
}
|
||||
|
||||
pub fn get_tags(&self) -> Vec<String> {
|
||||
self.tags.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn matches_search(&self, search: &SermonSearch) -> bool {
|
||||
if let Some(query) = &search.query {
|
||||
let query_lower = query.to_lowercase();
|
||||
if !self.title.to_lowercase().contains(&query_lower)
|
||||
&& !self.description.to_lowercase().contains(&query_lower)
|
||||
&& !self.speaker.to_lowercase().contains(&query_lower)
|
||||
&& !self.scripture_reference.to_lowercase().contains(&query_lower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(speaker) = &search.speaker {
|
||||
if !self.speaker.to_lowercase().contains(&speaker.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(category) = &search.category {
|
||||
if self.category != *category {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(series) = &search.series {
|
||||
match &self.series {
|
||||
Some(sermon_series) => {
|
||||
if !sermon_series.to_lowercase().contains(&series.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(date_from) = search.date_from {
|
||||
if self.date < date_from {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(date_to) = search.date_to {
|
||||
if self.date > date_to {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.featured_only {
|
||||
if !self.is_featured {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.has_video {
|
||||
if !self.has_video() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.has_audio {
|
||||
if !self.has_audio() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.has_transcript {
|
||||
if !self.has_transcript() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl SermonSeries {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active && self.end_date.map_or(true, |end| end > Utc::now())
|
||||
}
|
||||
|
||||
pub fn sermon_count(&self) -> usize {
|
||||
self.sermons.len()
|
||||
}
|
||||
|
||||
pub fn total_duration(&self) -> Option<u32> {
|
||||
if self.sermons.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Since we now use duration_string, we can't easily sum durations
|
||||
// Return None for now - this would need proper duration parsing if needed
|
||||
None
|
||||
}
|
||||
|
||||
pub fn latest_sermon(&self) -> Option<&Sermon> {
|
||||
self.sermons
|
||||
.iter()
|
||||
.max_by_key(|s| s.date)
|
||||
}
|
||||
|
||||
pub fn duration_formatted(&self) -> String {
|
||||
match self.total_duration() {
|
||||
Some(seconds) => {
|
||||
let minutes = seconds / 60;
|
||||
let hours = minutes / 60;
|
||||
let remaining_minutes = minutes % 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{}h {}m", hours, remaining_minutes)
|
||||
} else {
|
||||
format!("{}m", minutes)
|
||||
}
|
||||
}
|
||||
None => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
157
church-core/src/models/streaming.rs
Normal file
157
church-core/src/models/streaming.rs
Normal 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
Loading…
Reference in a new issue