Compare commits
9 Commits
d42b7cb307
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1659c07ea | ||
|
|
6f1d09cd77 | ||
|
|
b8f2d12011 | ||
|
|
dac8b43915 | ||
|
|
9edfe6aa62 | ||
|
|
107c208746 | ||
|
|
e5fdd0ecb8 | ||
|
|
0f74333a22 | ||
|
|
be2426c078 |
10
.env.example
10
.env.example
@@ -17,10 +17,12 @@ DATABASE_USER=membership_user
|
|||||||
DATABASE_PASSWORD=change_this_password
|
DATABASE_PASSWORD=change_this_password
|
||||||
DATABASE_NAME=membership_db
|
DATABASE_NAME=membership_db
|
||||||
|
|
||||||
# Square Payment Settings (to be added later)
|
# Square Payment Settings
|
||||||
SQUARE_ACCESS_TOKEN=your-square-access-token
|
# Get these from your Square Developer Dashboard: https://developer.squareup.com/apps
|
||||||
SQUARE_ENVIRONMENT=sandbox
|
SQUARE_ACCESS_TOKEN=your-square-access-token-here
|
||||||
SQUARE_LOCATION_ID=your-location-id
|
SQUARE_ENVIRONMENT=sandbox # Use 'sandbox' for testing, 'production' for live payments
|
||||||
|
SQUARE_LOCATION_ID=your-square-location-id-here
|
||||||
|
SQUARE_APPLICATION_ID=your-square-application-id-here # Required for Web Payments SDK
|
||||||
|
|
||||||
# SMTP2GO Email Settings
|
# SMTP2GO Email Settings
|
||||||
SMTP2GO_API_KEY=your-smtp2go-api-key
|
SMTP2GO_API_KEY=your-smtp2go-api-key
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ docker-compose logs -f
|
|||||||
|
|
||||||
**Admin**: admin@swanseaairport.org / admin123
|
**Admin**: admin@swanseaairport.org / admin123
|
||||||
|
|
||||||
**Database**: membership_user / SecureMembershipPass2024!
|
**Database**: Configured via environment variables (see .env file)
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ docker-compose up -d --build
|
|||||||
# Check status
|
# Check status
|
||||||
docker-compose ps
|
docker-compose ps
|
||||||
|
|
||||||
# Access MySQL CLI
|
# Access MySQL CLI (using environment variables)
|
||||||
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db
|
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
|
||||||
|
|
||||||
# Create database backup
|
# Create database backup
|
||||||
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql
|
docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Default Admin Access
|
## Default Admin Access
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -25,6 +25,11 @@ A comprehensive membership management system built with FastAPI, MySQL, and Dock
|
|||||||
```
|
```
|
||||||
membership/
|
membership/
|
||||||
├── backend/
|
├── backend/
|
||||||
|
│ ├── alembic/ # Database migration scripts
|
||||||
|
│ │ ├── versions/ # Migration files
|
||||||
|
│ │ ├── env.py # Migration environment
|
||||||
|
│ │ └── script.py.mako # Migration template
|
||||||
|
│ ├── alembic.ini # Alembic configuration
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/
|
│ │ ├── api/
|
||||||
│ │ │ ├── v1/
|
│ │ │ ├── v1/
|
||||||
@@ -46,7 +51,7 @@ membership/
|
|||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── database/
|
├── database/
|
||||||
│ └── init.sql # Database initialization
|
│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -90,57 +95,42 @@ membership/
|
|||||||
|
|
||||||
## Frontend Development vs Production
|
## Frontend Development vs Production
|
||||||
|
|
||||||
The frontend supports both development and production modes using Docker Compose profiles:
|
Choose your deployment mode by using the appropriate docker-compose file:
|
||||||
|
|
||||||
### Development Mode (Vite)
|
### Development Mode (Vite)
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
- Frontend served by Vite dev server on port 3500
|
||||||
- Hot reloading and development features
|
- Hot reloading and development features
|
||||||
- Access at: http://localhost:3500
|
- Access at: http://localhost:3500
|
||||||
- Start with: `docker-compose --profile dev up -d`
|
|
||||||
|
|
||||||
### Production Mode (Nginx)
|
### Production Mode (Nginx)
|
||||||
- Optimized static files served by Nginx
|
|
||||||
- Access at: http://localhost:8080
|
|
||||||
- Start with: `docker-compose --profile prod up -d`
|
|
||||||
|
|
||||||
### Usage Examples
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start all services in development mode
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
docker-compose --profile dev up -d
|
```
|
||||||
|
- Frontend served by Nginx on port 8050
|
||||||
|
- Optimized static files, production-ready
|
||||||
|
- Access at: http://localhost:8050
|
||||||
|
|
||||||
# Start all services in production mode
|
### Configuration
|
||||||
docker-compose --profile prod up -d
|
|
||||||
|
|
||||||
# Start only the frontend in development mode
|
Set your preferred defaults in `.env`:
|
||||||
docker-compose --profile dev up -d frontend
|
```bash
|
||||||
|
# Deployment mode (for reference only)
|
||||||
|
MODE=dev
|
||||||
|
|
||||||
# Start only the frontend in production mode
|
# Frontend allowed hosts (comma-separated)
|
||||||
docker-compose --profile prod up -d frontend-prod
|
VITE_ALLOWED_HOSTS=sasaprod,localhost,members.sasalliance.org
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stopping Services
|
||||||
|
```bash
|
||||||
# Stop all services
|
# Stop all services
|
||||||
docker-compose down
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
|
||||||
# Check which services are running
|
|
||||||
docker-compose ps
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Profile Details
|
|
||||||
|
|
||||||
- **`dev` profile**: Includes the `frontend` service (Vite dev server)
|
|
||||||
- **`prod` profile**: Includes the `frontend-prod` service (Nginx)
|
|
||||||
- **Default**: No frontend service runs unless you specify a profile
|
|
||||||
|
|
||||||
### For Production Deployment
|
|
||||||
|
|
||||||
When deploying to production with Caddy:
|
|
||||||
|
|
||||||
1. Start services in production mode:
|
|
||||||
```bash
|
|
||||||
docker-compose --profile prod up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Configure Caddy to proxy to `localhost:8080` for the frontend and `localhost:6000` for the API
|
|
||||||
|
|
||||||
## Default Credentials
|
## Default Credentials
|
||||||
|
|
||||||
**Admin Account**:
|
**Admin Account**:
|
||||||
@@ -220,16 +210,36 @@ docker-compose ps
|
|||||||
### Database Operations
|
### Database Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Access MySQL CLI
|
# Access MySQL CLI (using environment variables)
|
||||||
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db
|
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
|
||||||
|
|
||||||
# Create backup
|
# Create backup
|
||||||
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql
|
docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
# Restore database
|
# Restore database
|
||||||
docker exec -i membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db < backup.sql
|
docker exec -i membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" < backup.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
The application uses Alembic for database schema migrations. Migrations are automatically run when the backend container starts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new migration (after making model changes)
|
||||||
|
sudo docker compose exec backend alembic revision --autogenerate -m "Description of changes"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
sudo docker compose exec backend alembic upgrade head
|
||||||
|
|
||||||
|
# View migration status
|
||||||
|
sudo docker compose exec backend alembic current
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
sudo docker compose exec backend alembic history
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `database/init.sql` file is deprecated. All schema changes should now be made through Alembic migrations.
|
||||||
|
|
||||||
## API Testing
|
## API Testing
|
||||||
|
|
||||||
You can use the interactive API documentation at http://localhost:8000/docs to test endpoints:
|
You can use the interactive API documentation at http://localhost:8000/docs to test endpoints:
|
||||||
|
|||||||
182
SQUARE_CHECKLIST.md
Normal file
182
SQUARE_CHECKLIST.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Square Payment Integration - Implementation Checklist
|
||||||
|
|
||||||
|
## ✅ Completed Tasks
|
||||||
|
|
||||||
|
### Backend Implementation
|
||||||
|
- [x] Added Square SDK to `requirements.txt` (squareup==43.2.0.20251016)
|
||||||
|
- [x] Created `square_service.py` with payment processing logic
|
||||||
|
- [x] Added Square configuration to `config.py` (SQUARE_APPLICATION_ID)
|
||||||
|
- [x] Created Square payment schemas in `schemas.py`
|
||||||
|
- [x] SquarePaymentRequest
|
||||||
|
- [x] SquarePaymentResponse
|
||||||
|
- [x] SquareRefundRequest
|
||||||
|
- [x] Added Square payment endpoints to `payments.py`
|
||||||
|
- [x] GET /api/v1/payments/config/square
|
||||||
|
- [x] POST /api/v1/payments/square/process
|
||||||
|
- [x] POST /api/v1/payments/square/refund
|
||||||
|
|
||||||
|
### Frontend Implementation
|
||||||
|
- [x] Created `SquarePayment.tsx` component
|
||||||
|
- [x] Square Web Payments SDK integration
|
||||||
|
- [x] Card input form
|
||||||
|
- [x] Payment tokenization
|
||||||
|
- [x] Error handling
|
||||||
|
- [x] Updated `MembershipSetup.tsx` component
|
||||||
|
- [x] Payment method selection UI
|
||||||
|
- [x] Integration with SquarePayment
|
||||||
|
- [x] Cash payment option
|
||||||
|
- [x] Improved flow logic
|
||||||
|
- [x] Added Square SDK script to `index.html`
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- [x] Updated `.env.example` with Square variables and comments
|
||||||
|
- [x] Verified all Square config variables in `config.py`
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide
|
||||||
|
- [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details
|
||||||
|
- [x] Created `SQUARE_QUICKSTART.md` - Quick start guide
|
||||||
|
- [x] Created `deploy-square.sh` - Deployment helper script
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- [x] No Python syntax errors
|
||||||
|
- [x] Proper error handling implemented
|
||||||
|
- [x] Security best practices followed
|
||||||
|
- [x] PCI compliance maintained (tokenization)
|
||||||
|
|
||||||
|
## 📋 Deployment Checklist
|
||||||
|
|
||||||
|
Before deploying, complete these steps:
|
||||||
|
|
||||||
|
### 1. Square Account Setup
|
||||||
|
- [ ] Create/login to Square Developer account
|
||||||
|
- [ ] Create application in Square Dashboard
|
||||||
|
- [ ] Copy Sandbox credentials:
|
||||||
|
- [ ] Access Token
|
||||||
|
- [ ] Application ID
|
||||||
|
- [ ] Location ID
|
||||||
|
|
||||||
|
### 2. Environment Configuration
|
||||||
|
- [ ] Create/update `.env` file
|
||||||
|
- [ ] Add SQUARE_ACCESS_TOKEN
|
||||||
|
- [ ] Add SQUARE_APPLICATION_ID
|
||||||
|
- [ ] Add SQUARE_LOCATION_ID
|
||||||
|
- [ ] Set SQUARE_ENVIRONMENT=sandbox
|
||||||
|
|
||||||
|
### 3. Deployment
|
||||||
|
- [ ] Run `./deploy-square.sh` OR
|
||||||
|
- [ ] Run `docker-compose down`
|
||||||
|
- [ ] Run `docker-compose up -d --build`
|
||||||
|
- [ ] Verify containers are running: `docker-compose ps`
|
||||||
|
|
||||||
|
### 4. Testing
|
||||||
|
- [ ] Access frontend at http://localhost:3000
|
||||||
|
- [ ] Login/register a user
|
||||||
|
- [ ] Navigate to membership setup
|
||||||
|
- [ ] Select a membership tier
|
||||||
|
- [ ] Choose "Credit/Debit Card" payment
|
||||||
|
- [ ] Test with card: 4111 1111 1111 1111
|
||||||
|
- [ ] Verify payment succeeds
|
||||||
|
- [ ] Check membership is activated
|
||||||
|
- [ ] Verify email is sent
|
||||||
|
- [ ] Test cash payment option
|
||||||
|
- [ ] Verify admin can see payments
|
||||||
|
|
||||||
|
### 5. Admin Testing
|
||||||
|
- [ ] Login as admin
|
||||||
|
- [ ] View all payments
|
||||||
|
- [ ] Test payment refund (if needed)
|
||||||
|
- [ ] Approve cash payment
|
||||||
|
|
||||||
|
### 6. Production Preparation (When Ready)
|
||||||
|
- [ ] Get Square production credentials
|
||||||
|
- [ ] Complete Square account verification
|
||||||
|
- [ ] Test in sandbox thoroughly first
|
||||||
|
- [ ] Update `.env` with production credentials
|
||||||
|
- [ ] Change SQUARE_ENVIRONMENT=production
|
||||||
|
- [ ] Update Square SDK URL in index.html to production
|
||||||
|
- [ ] Test with real card (small amount, can refund)
|
||||||
|
- [ ] Monitor logs and Square Dashboard
|
||||||
|
|
||||||
|
## 🎯 Quick Test Script
|
||||||
|
|
||||||
|
After deployment, run these commands to verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend is running
|
||||||
|
curl http://localhost:8000/api/v1/payments/config/square
|
||||||
|
|
||||||
|
# Expected output (with your actual IDs):
|
||||||
|
# {
|
||||||
|
# "application_id": "sandbox-sq0idb-...",
|
||||||
|
# "location_id": "LXXX...",
|
||||||
|
# "environment": "sandbox"
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Check frontend is running
|
||||||
|
curl http://localhost:3000
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs backend | grep -i square
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Testing Matrix
|
||||||
|
|
||||||
|
| Test Case | Expected Result | Status |
|
||||||
|
|-----------|----------------|--------|
|
||||||
|
| Square payment - valid card | Payment success, membership activated | [ ] |
|
||||||
|
| Square payment - declined card | Error shown, payment not created | [ ] |
|
||||||
|
| Cash payment | Payment pending, membership pending | [ ] |
|
||||||
|
| Admin approve cash payment | Membership activated | [ ] |
|
||||||
|
| Admin refund Square payment | Payment refunded in Square | [ ] |
|
||||||
|
| Email sent on activation | User receives email | [ ] |
|
||||||
|
|
||||||
|
## 🔍 Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Square SDK installed
|
||||||
|
docker-compose exec backend pip list | grep square
|
||||||
|
|
||||||
|
# Check configuration loaded
|
||||||
|
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
|
||||||
|
|
||||||
|
# Check database has payments
|
||||||
|
docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
|
||||||
|
|
||||||
|
# Check frontend files
|
||||||
|
ls -la frontend/src/components/SquarePayment.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Common Issues & Solutions
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| "Module not found: squareup" | Rebuild backend: `docker-compose build backend` |
|
||||||
|
| "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers |
|
||||||
|
| Square SDK not loading | Check browser console, verify script tag in index.html |
|
||||||
|
| Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct |
|
||||||
|
| Payment fails with location error | Verify SQUARE_LOCATION_ID matches your account |
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- All Square credentials are for **SANDBOX** by default
|
||||||
|
- No real money is charged in sandbox mode
|
||||||
|
- Test cards work only in sandbox environment
|
||||||
|
- Keep `.env` file secure and never commit to git
|
||||||
|
- Monitor Square Dashboard for transaction details
|
||||||
|
- Check backend logs for detailed error messages
|
||||||
|
|
||||||
|
## ✅ Sign-off
|
||||||
|
|
||||||
|
- [ ] All backend code implemented and tested
|
||||||
|
- [ ] All frontend code implemented and tested
|
||||||
|
- [ ] Documentation completed
|
||||||
|
- [ ] Deployment script created and tested
|
||||||
|
- [ ] Environment variables documented
|
||||||
|
- [ ] Ready for Square account setup
|
||||||
|
- [ ] Ready for deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Status**: ✅ COMPLETE - Ready for Square credentials and deployment
|
||||||
|
**Next Step**: Follow SQUARE_QUICKSTART.md to get Square credentials and deploy
|
||||||
206
SQUARE_IMPLEMENTATION.md
Normal file
206
SQUARE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Square Payment Integration - Implementation Summary
|
||||||
|
|
||||||
|
## What Has Been Implemented
|
||||||
|
|
||||||
|
Square payment processing has been successfully integrated into the SASA Membership Portal as an alternative to cash/dummy payments.
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. **`backend/app/services/square_service.py`** - Square payment service
|
||||||
|
- Payment creation and processing
|
||||||
|
- Payment retrieval and verification
|
||||||
|
- Refund processing
|
||||||
|
- Customer management
|
||||||
|
|
||||||
|
2. **`backend/app/schemas/schemas.py`** - Updated with Square schemas
|
||||||
|
- `SquarePaymentRequest` - Payment request schema (accepts `tier_id` instead of `membership_id`)
|
||||||
|
- `SquarePaymentResponse` - Payment response schema (includes created `membership_id`)
|
||||||
|
- `SquareRefundRequest` - Refund request schema
|
||||||
|
|
||||||
|
3. **`backend/app/api/v1/payments.py`** - Updated with Square endpoints
|
||||||
|
- `GET /api/v1/payments/config/square` - Get Square config for frontend
|
||||||
|
- `POST /api/v1/payments/square/process` - Process Square payment
|
||||||
|
- `POST /api/v1/payments/square/refund` - Refund payment (admin only)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. **`frontend/src/components/SquarePayment.tsx`** - Square payment component
|
||||||
|
- Square Web Payments SDK integration
|
||||||
|
- Card input form
|
||||||
|
- Payment tokenization and processing
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
2. **`frontend/src/components/MembershipSetup.tsx`** - Updated membership flow
|
||||||
|
- Payment method selection (Square or Cash)
|
||||||
|
- Integration with SquarePayment component
|
||||||
|
- Improved user experience
|
||||||
|
|
||||||
|
3. **`frontend/index.html`** - Added Square SDK script tag
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
1. **`backend/requirements.txt`** - Updated with:
|
||||||
|
- `squareup==43.2.0.20251016`
|
||||||
|
- `pydantic==2.10.3` (upgraded from 2.5.0)
|
||||||
|
- `pydantic-settings==2.6.1` (upgraded from 2.1.0)
|
||||||
|
- `python-dateutil==2.8.2` (for membership date calculations)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
1. **`backend/requirements.txt`** - Added Square SDK and python-dateutil dependencies
|
||||||
|
2. **`.env`** - Configured with Square sandbox credentials
|
||||||
|
3. **`backend/app/core/config.py`** - Added SQUARE_APPLICATION_ID
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
1. **`SQUARE_PAYMENT_SETUP.md`** - Comprehensive setup guide
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `backend/requirements.txt` - Added Square SDK and updated pydantic versions
|
||||||
|
- `backend/app/core/config.py` - Added `SQUARE_APPLICATION_ID` setting
|
||||||
|
- `backend/app/schemas/schemas.py` - Added Square payment schemas
|
||||||
|
- `backend/app/schemas/__init__.py` - Exported Square schemas
|
||||||
|
- `backend/app/api/v1/payments.py` - Added Square payment endpoints with membership creation
|
||||||
|
- `frontend/src/components/MembershipSetup.tsx` - Added payment method selection and flow logic
|
||||||
|
- `frontend/src/components/SquarePayment.tsx` - Created complete Square payment component
|
||||||
|
- `frontend/index.html` - Added Square SDK script
|
||||||
|
- `.env` - Configured with Square sandbox credentials
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Payment Processing
|
||||||
|
- ✅ Credit/debit card payments via Square
|
||||||
|
- ✅ Secure tokenization (PCI-compliant)
|
||||||
|
- ✅ Sandbox environment configured and tested
|
||||||
|
- ✅ Membership created ONLY after successful payment (no orphaned PENDING memberships)
|
||||||
|
- ✅ Automatic membership activation with ACTIVE status on successful payment
|
||||||
|
- ✅ Payment confirmation emails
|
||||||
|
- ✅ Transaction ID tracking
|
||||||
|
- ✅ User-friendly error messages for declined cards
|
||||||
|
- ✅ Failed payments don't create memberships (users can retry)
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- ✅ Payment method selection (Square or Cash)
|
||||||
|
- ✅ Integrated card payment form with Square Web Payments SDK
|
||||||
|
- ✅ Real-time validation and error handling
|
||||||
|
- ✅ User-friendly error messages (not raw API errors)
|
||||||
|
- ✅ Clear payment status feedback
|
||||||
|
- ✅ Different confirmation messages for Square (Active) vs Cash (Pending)
|
||||||
|
- ✅ Test mode indicators in sandbox
|
||||||
|
- ✅ Errors clear when changing payment methods
|
||||||
|
- ✅ Ability to retry failed payments without issues
|
||||||
|
|
||||||
|
### Admin Features
|
||||||
|
- ✅ Payment refund capability
|
||||||
|
- ✅ Full payment history
|
||||||
|
- ✅ Transaction tracking
|
||||||
|
- ✅ Manual payment approval for cash
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- ✅ Card data never touches your server
|
||||||
|
- ✅ Square handles PCI compliance
|
||||||
|
- ✅ Idempotency keys prevent duplicate charges
|
||||||
|
- ✅ Authentication required for all payment endpoints
|
||||||
|
- ✅ Admin-only refund access
|
||||||
|
|
||||||
|
## Next Steps for Production
|
||||||
|
|
||||||
|
### When Ready to Go Live
|
||||||
|
1. Get approved by Square for production processing
|
||||||
|
2. Update `.env` with production credentials:
|
||||||
|
```bash
|
||||||
|
SQUARE_ACCESS_TOKEN=your-production-access-token
|
||||||
|
SQUARE_ENVIRONMENT=production
|
||||||
|
SQUARE_LOCATION_ID=your-production-location-id
|
||||||
|
SQUARE_APPLICATION_ID=your-production-application-id
|
||||||
|
```
|
||||||
|
3. Test thoroughly in production sandbox first
|
||||||
|
4. Monitor first transactions closely
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For detailed setup instructions, see `SQUARE_PAYMENT_SETUP.md`
|
||||||
|
|
||||||
|
For Square-specific questions:
|
||||||
|
- Square Developer Docs: https://developer.squareup.com/docs
|
||||||
|
- Square Support: https://squareup.com/help/contact
|
||||||
|
|
||||||
|
## Test Card Numbers (Sandbox)
|
||||||
|
|
||||||
|
| Card Number | Result | User Message |
|
||||||
|
|---------------------|---------------------------|--------------|
|
||||||
|
| 4111 1111 1111 1111 | Success | Payment Successful! |
|
||||||
|
| 4000 0000 0000 0002 | Generic Decline | Your card was declined. Please try a different payment method. |
|
||||||
|
| 4000 0000 0000 0036 | Insufficient Funds | Insufficient funds. Please try a different payment method. |
|
||||||
|
| 5105 1051 0510 5100 | Success (Mastercard) | Payment Successful! |
|
||||||
|
|
||||||
|
**CVV:** Any 3 digits (e.g., 111)
|
||||||
|
**Expiry:** Any future date (e.g., 12/26, 01/27)
|
||||||
|
|
||||||
|
## Payment Flow
|
||||||
|
|
||||||
|
### Square Payment Flow
|
||||||
|
1. User selects membership tier
|
||||||
|
2. User chooses "Credit/Debit Card" payment method
|
||||||
|
3. Square payment form appears with card input
|
||||||
|
4. User enters card details
|
||||||
|
5. Square SDK tokenizes card (card data never touches your server)
|
||||||
|
6. Token sent to backend with tier_id
|
||||||
|
7. Backend processes payment with Square API
|
||||||
|
8. **If successful:**
|
||||||
|
- Membership created with ACTIVE status
|
||||||
|
- Payment record created with COMPLETED status and transaction ID
|
||||||
|
- Confirmation email sent
|
||||||
|
- User sees "Payment Successful!" message
|
||||||
|
9. **If declined:**
|
||||||
|
- No membership created
|
||||||
|
- User sees friendly error message
|
||||||
|
- User can retry with different card
|
||||||
|
10. User returns to dashboard
|
||||||
|
|
||||||
|
### Cash Payment Flow
|
||||||
|
1. User selects membership tier
|
||||||
|
2. User chooses "Cash" payment method
|
||||||
|
3. Membership created immediately with PENDING status
|
||||||
|
4. User sees "Membership Application Submitted!" message
|
||||||
|
5. Admin reviews and approves payment
|
||||||
|
6. Membership activated to ACTIVE status
|
||||||
|
7. Activation email sent
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Critical Fixes Applied
|
||||||
|
1. **Payment Flow Logic**: Memberships are now created ONLY after successful Square payment, preventing orphaned PENDING memberships from failed payment attempts
|
||||||
|
2. **Square SDK API**: Corrected method calls to use `payments.create()`, `payments.get()`, `customers.create()` with keyword arguments (not body dict)
|
||||||
|
3. **Error Handling**: User-friendly error messages instead of raw Square API responses
|
||||||
|
4. **Response Handling**: Fixed to check `result.errors` instead of `.is_success()` method
|
||||||
|
5. **Frontend URLs**: Using relative paths (`/api/v1/...`) instead of hardcoded localhost
|
||||||
|
6. **Error Clearing**: Errors properly cleared when switching payment methods
|
||||||
|
7. **Status Display**: Different confirmation messages for Active (Square) vs Pending (Cash) memberships
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Square SDK version: `43.2.0.20251016`
|
||||||
|
- Environment: Sandbox (configured in `.env`)
|
||||||
|
- Payment currency: GBP (pence conversion: amount * 100)
|
||||||
|
- Pydantic upgraded to 2.10.3 to resolve dependency conflicts
|
||||||
|
- Added `python-dateutil` for membership date calculations (1 year from start)
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
**✅ FULLY FUNCTIONAL AND TESTED**
|
||||||
|
|
||||||
|
The Square payment integration is complete, tested, and working in sandbox mode:
|
||||||
|
- Successful payments create ACTIVE memberships immediately
|
||||||
|
- Declined payments show user-friendly errors without creating memberships
|
||||||
|
- Users can retry failed payments
|
||||||
|
- Cash payments still work with PENDING status for admin approval
|
||||||
|
- All payment flows properly tested with Square sandbox test cards
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Square payment integration is **fully implemented, tested, and working** in sandbox mode. Users can now:
|
||||||
|
- Pay for memberships securely with credit/debit cards
|
||||||
|
- Choose between Square payments (instant activation) and cash payments (admin approval)
|
||||||
|
- Receive immediate membership activation on successful Square payment
|
||||||
|
- See clear, user-friendly error messages for declined cards
|
||||||
|
- Retry failed payments without issues
|
||||||
|
|
||||||
|
All payment data is handled securely by Square, ensuring PCI compliance. The system properly prevents orphaned memberships from failed payment attempts and provides different user experiences for Square (ACTIVE immediately) vs Cash (PENDING for approval) payment methods.
|
||||||
229
SQUARE_PAYMENT_SETUP.md
Normal file
229
SQUARE_PAYMENT_SETUP.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Square Payment Integration Setup Guide
|
||||||
|
|
||||||
|
This guide walks you through setting up Square payment processing for the SASA Membership Portal.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The application supports two payment methods:
|
||||||
|
- **Square Payments**: Credit/debit card payments processed through Square's Web Payments SDK
|
||||||
|
- **Cash Payments**: Manual payments recorded by administrators
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A Square Developer account
|
||||||
|
2. Access to the Square Developer Dashboard
|
||||||
|
3. Square Sandbox credentials for testing (included with all Square accounts)
|
||||||
|
|
||||||
|
## Setup Steps
|
||||||
|
|
||||||
|
### 1. Create a Square Developer Account
|
||||||
|
|
||||||
|
1. Go to [Square Developer Portal](https://developer.squareup.com/)
|
||||||
|
2. Sign up for a free developer account or log in with your existing Square account
|
||||||
|
3. Navigate to the [Applications Dashboard](https://developer.squareup.com/apps)
|
||||||
|
|
||||||
|
### 2. Create or Select an Application
|
||||||
|
|
||||||
|
1. In the Applications Dashboard, create a new application or select an existing one
|
||||||
|
2. Name your application (e.g., "SASA Membership Portal")
|
||||||
|
3. Click "Save"
|
||||||
|
|
||||||
|
### 3. Get Your Credentials
|
||||||
|
|
||||||
|
#### For Sandbox (Testing):
|
||||||
|
|
||||||
|
1. In your application, go to the **Sandbox** tab
|
||||||
|
2. Copy the following credentials:
|
||||||
|
- **Sandbox Access Token**: Found under "Sandbox Access Token"
|
||||||
|
- **Sandbox Application ID**: Found under "Sandbox Application ID"
|
||||||
|
- **Sandbox Location ID**: Click on "Locations" to find your sandbox location ID
|
||||||
|
|
||||||
|
#### For Production (Live Payments):
|
||||||
|
|
||||||
|
1. In your application, go to the **Production** tab
|
||||||
|
2. Copy the following credentials:
|
||||||
|
- **Production Access Token**: Found under "Production Access Token"
|
||||||
|
- **Production Application ID**: Found under "Production Application ID"
|
||||||
|
- **Production Location ID**: Click on "Locations" to find your production location ID
|
||||||
|
|
||||||
|
### 4. Configure Environment Variables
|
||||||
|
|
||||||
|
Update your `.env` file with the Square credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For Sandbox (Testing)
|
||||||
|
SQUARE_ACCESS_TOKEN=EAAAl... # Your Sandbox Access Token
|
||||||
|
SQUARE_ENVIRONMENT=sandbox
|
||||||
|
SQUARE_LOCATION_ID=LXXX... # Your Sandbox Location ID
|
||||||
|
SQUARE_APPLICATION_ID=sandbox-sq0idb-... # Your Sandbox Application ID
|
||||||
|
|
||||||
|
# For Production (Live Payments)
|
||||||
|
SQUARE_ACCESS_TOKEN=EAAAl... # Your Production Access Token
|
||||||
|
SQUARE_ENVIRONMENT=production
|
||||||
|
SQUARE_LOCATION_ID=LXXX... # Your Production Location ID
|
||||||
|
SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Restart the Application
|
||||||
|
|
||||||
|
After updating the environment variables, restart your Docker containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing with Sandbox
|
||||||
|
|
||||||
|
Square provides test card numbers for sandbox testing:
|
||||||
|
|
||||||
|
### Test Card Numbers
|
||||||
|
|
||||||
|
| Card Number | Result |
|
||||||
|
|---------------------|----------------------------------|
|
||||||
|
| 4111 1111 1111 1111 | Successful payment |
|
||||||
|
| 4000 0000 0000 0002 | Card declined - Insufficient funds |
|
||||||
|
| 4000 0000 0000 0010 | Card declined - CVV failure |
|
||||||
|
| 5105 1051 0510 5100 | Successful payment (Mastercard) |
|
||||||
|
|
||||||
|
**Test CVV:** Any 3-digit number (e.g., 111)
|
||||||
|
**Test Expiration:** Any future date (e.g., 12/25)
|
||||||
|
**Test Postal Code:** Any valid postal code (e.g., 12345)
|
||||||
|
|
||||||
|
### Testing the Payment Flow
|
||||||
|
|
||||||
|
1. Log in to the membership portal
|
||||||
|
2. Navigate to membership setup
|
||||||
|
3. Select a membership tier
|
||||||
|
4. Choose "Credit/Debit Card" as payment method
|
||||||
|
5. Use one of the test card numbers above
|
||||||
|
6. Complete the payment
|
||||||
|
|
||||||
|
In sandbox mode, no real money is charged.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### Backend (`backend/app/`)
|
||||||
|
|
||||||
|
- **`services/square_service.py`**: Core Square integration service
|
||||||
|
- Payment processing
|
||||||
|
- Payment retrieval
|
||||||
|
- Refund processing
|
||||||
|
- Customer creation
|
||||||
|
|
||||||
|
- **`api/v1/payments.py`**: Payment endpoints
|
||||||
|
- `GET /api/v1/payments/config/square`: Get Square configuration for frontend
|
||||||
|
- `POST /api/v1/payments/square/process`: Process Square payment
|
||||||
|
- `POST /api/v1/payments/square/refund`: Refund a payment (admin only)
|
||||||
|
|
||||||
|
- **`schemas/schemas.py`**: Payment schemas
|
||||||
|
- `SquarePaymentRequest`: Request schema for processing payments
|
||||||
|
- `SquarePaymentResponse`: Response schema for payment results
|
||||||
|
- `SquareRefundRequest`: Request schema for refunds
|
||||||
|
|
||||||
|
### Frontend (`frontend/src/`)
|
||||||
|
|
||||||
|
- **`components/SquarePayment.tsx`**: Square Web Payments SDK integration
|
||||||
|
- Card input form
|
||||||
|
- Token generation
|
||||||
|
- Payment submission
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
- **`components/MembershipSetup.tsx`**: Updated membership flow
|
||||||
|
- Payment method selection
|
||||||
|
- Integration with SquarePayment component
|
||||||
|
- Cash payment option
|
||||||
|
|
||||||
|
- **`index.html`**: Square Web Payments SDK script tag
|
||||||
|
|
||||||
|
## Payment Flow
|
||||||
|
|
||||||
|
1. **User selects membership tier** → Creates pending membership in database
|
||||||
|
2. **User chooses payment method**:
|
||||||
|
- **Square**: Square payment form loads
|
||||||
|
- **Cash**: Simple confirmation dialog
|
||||||
|
3. **Square Payment**:
|
||||||
|
- User enters card details
|
||||||
|
- Card is tokenized by Square SDK (PCI-compliant)
|
||||||
|
- Token sent to backend
|
||||||
|
- Backend processes payment with Square API
|
||||||
|
- Payment record created in database
|
||||||
|
- Membership activated
|
||||||
|
- Confirmation email sent
|
||||||
|
4. **Cash Payment**:
|
||||||
|
- Payment record created with "pending" status
|
||||||
|
- Membership remains "pending"
|
||||||
|
- Admin must manually approve
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- **PCI Compliance**: Card data never touches your server (handled by Square SDK)
|
||||||
|
- **Tokenization**: Card details are converted to secure tokens
|
||||||
|
- **Idempotency**: Prevents duplicate payments
|
||||||
|
- **Environment Separation**: Clear sandbox/production separation
|
||||||
|
- **Authorization**: Payment endpoints require authentication
|
||||||
|
- **Admin Controls**: Refunds require admin privileges
|
||||||
|
|
||||||
|
## Currency
|
||||||
|
|
||||||
|
The system is configured for **GBP (British Pounds)**. To change the currency:
|
||||||
|
|
||||||
|
1. Update `square_service.py` line 56: Change `'currency': 'GBP'`
|
||||||
|
2. Update frontend display symbols in `MembershipSetup.tsx` and `SquarePayment.tsx`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Failed to load payment configuration"
|
||||||
|
|
||||||
|
- Check that `SQUARE_APPLICATION_ID` is set in `.env`
|
||||||
|
- Verify the backend is running and accessible
|
||||||
|
- Check browser console for CORS errors
|
||||||
|
|
||||||
|
### "Payment processing failed"
|
||||||
|
|
||||||
|
- Verify `SQUARE_ACCESS_TOKEN` is valid
|
||||||
|
- Check `SQUARE_LOCATION_ID` matches your account
|
||||||
|
- Ensure `SQUARE_ENVIRONMENT` matches your token type (sandbox/production)
|
||||||
|
- Check backend logs for detailed error messages
|
||||||
|
|
||||||
|
### "Card tokenization failed"
|
||||||
|
|
||||||
|
- Ensure Square SDK loaded (check Network tab in browser DevTools)
|
||||||
|
- Verify `SQUARE_APPLICATION_ID` matches the environment
|
||||||
|
- Check that test card numbers are valid for sandbox
|
||||||
|
|
||||||
|
### Database errors
|
||||||
|
|
||||||
|
- Ensure `squareup` package is installed: `pip install squareup==43.2.0.20251016`
|
||||||
|
- Restart backend container after updating requirements.txt
|
||||||
|
|
||||||
|
## Going Live with Production
|
||||||
|
|
||||||
|
Before accepting real payments:
|
||||||
|
|
||||||
|
1. **Get approved by Square**: Complete verification in Square Dashboard
|
||||||
|
2. **Update credentials**: Switch to production credentials in `.env`
|
||||||
|
3. **Change environment**: Set `SQUARE_ENVIRONMENT=production`
|
||||||
|
4. **Update SDK URL**: In `frontend/index.html`, change:
|
||||||
|
```html
|
||||||
|
<!-- Change from sandbox -->
|
||||||
|
<script src="https://sandbox.web.squarecdn.com/v1/square.js"></script>
|
||||||
|
|
||||||
|
<!-- To production -->
|
||||||
|
<script src="https://web.squarecdn.com/v1/square.js"></script>
|
||||||
|
```
|
||||||
|
5. **Test thoroughly**: Test the complete flow with real cards (you can refund these)
|
||||||
|
6. **Monitor**: Watch for errors in logs and Square Dashboard
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Square Documentation**: https://developer.squareup.com/docs/web-payments/overview
|
||||||
|
- **Square Support**: https://squareup.com/help/contact
|
||||||
|
- **API Reference**: https://developer.squareup.com/reference/square
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Square Web Payments SDK Guide](https://developer.squareup.com/docs/web-payments/overview)
|
||||||
|
- [Square Testing Guide](https://developer.squareup.com/docs/testing/test-values)
|
||||||
|
- [Square API Reference](https://developer.squareup.com/reference/square)
|
||||||
|
- [PCI Compliance Info](https://developer.squareup.com/docs/security)
|
||||||
164
SQUARE_QUICKSTART.md
Normal file
164
SQUARE_QUICKSTART.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Square Payment Integration - Quick Start
|
||||||
|
|
||||||
|
## ✅ Implementation Complete
|
||||||
|
|
||||||
|
Square payment processing has been successfully integrated into your membership portal!
|
||||||
|
|
||||||
|
## 🎯 What You Can Do Now
|
||||||
|
|
||||||
|
Users can now pay for memberships using:
|
||||||
|
1. **Credit/Debit Cards** - Processed securely through Square
|
||||||
|
2. **Cash** - Recorded as pending, requires admin approval
|
||||||
|
|
||||||
|
## 🚀 Quick Start Guide
|
||||||
|
|
||||||
|
### Step 1: Get Square Credentials (5 minutes)
|
||||||
|
|
||||||
|
1. Go to [Square Developer Portal](https://developer.squareup.com/)
|
||||||
|
2. Sign up or log in
|
||||||
|
3. Create a new application or select existing one
|
||||||
|
4. Copy these credentials from the **Sandbox** tab:
|
||||||
|
- Sandbox Access Token
|
||||||
|
- Sandbox Application ID
|
||||||
|
- Sandbox Location ID
|
||||||
|
|
||||||
|
### Step 2: Configure Environment Variables
|
||||||
|
|
||||||
|
Edit your `.env` file and add:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SQUARE_ACCESS_TOKEN=EAAAl...your-sandbox-token...
|
||||||
|
SQUARE_ENVIRONMENT=sandbox
|
||||||
|
SQUARE_LOCATION_ID=LXXX...your-location-id...
|
||||||
|
SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Deploy the Changes
|
||||||
|
|
||||||
|
Run the deployment script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy-square.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Test It Out!
|
||||||
|
|
||||||
|
1. Open http://localhost:3000
|
||||||
|
2. Register/login
|
||||||
|
3. Go to "Setup Membership"
|
||||||
|
4. Select a tier
|
||||||
|
5. Choose "Credit/Debit Card"
|
||||||
|
6. Use test card: **4111 1111 1111 1111**
|
||||||
|
- CVV: 111
|
||||||
|
- Expiry: 12/25
|
||||||
|
- Postal Code: 12345
|
||||||
|
|
||||||
|
## 📁 Files Changed/Created
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- ✅ `backend/app/services/square_service.py` - NEW
|
||||||
|
- ✅ `backend/app/api/v1/payments.py` - UPDATED
|
||||||
|
- ✅ `backend/app/schemas/schemas.py` - UPDATED
|
||||||
|
- ✅ `backend/app/core/config.py` - UPDATED
|
||||||
|
- ✅ `backend/requirements.txt` - UPDATED
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- ✅ `frontend/src/components/SquarePayment.tsx` - NEW
|
||||||
|
- ✅ `frontend/src/components/MembershipSetup.tsx` - UPDATED
|
||||||
|
- ✅ `frontend/index.html` - UPDATED
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- ✅ `.env.example` - UPDATED
|
||||||
|
- ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
|
||||||
|
- ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
|
||||||
|
- ✅ `deploy-square.sh` - NEW (deployment helper)
|
||||||
|
|
||||||
|
## 🔧 Key Features
|
||||||
|
|
||||||
|
- ✅ Secure card payment processing via Square
|
||||||
|
- ✅ PCI-compliant (card data never touches your server)
|
||||||
|
- ✅ Automatic membership activation on payment success
|
||||||
|
- ✅ Email confirmations
|
||||||
|
- ✅ Admin refund capability
|
||||||
|
- ✅ Payment history tracking
|
||||||
|
- ✅ Sandbox testing support
|
||||||
|
- ✅ Production-ready
|
||||||
|
|
||||||
|
## 📊 Payment Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User → Select Tier → Choose Payment Method
|
||||||
|
↓
|
||||||
|
Square: Enter Card → Tokenize → Process → ✅ Active Membership
|
||||||
|
Cash: Confirm → ⏳ Pending → Admin Approval → ✅ Active Membership
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Test Cards (Sandbox Only)
|
||||||
|
|
||||||
|
| Card | Result |
|
||||||
|
|---------------------|------------------|
|
||||||
|
| 4111 1111 1111 1111 | ✅ Success |
|
||||||
|
| 4000 0000 0000 0002 | ❌ Declined |
|
||||||
|
| 5105 1051 0510 5100 | ✅ Success (MC) |
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- **Setup Guide**: `SQUARE_PAYMENT_SETUP.md` - Complete setup instructions
|
||||||
|
- **Implementation**: `SQUARE_IMPLEMENTATION.md` - Technical details
|
||||||
|
- **Square Docs**: https://developer.squareup.com/docs/web-payments/overview
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't start?
|
||||||
|
```bash
|
||||||
|
docker-compose logs backend
|
||||||
|
```
|
||||||
|
Check for missing dependencies or configuration errors.
|
||||||
|
|
||||||
|
### Square config endpoint fails?
|
||||||
|
Make sure `SQUARE_APPLICATION_ID` is in your `.env` file.
|
||||||
|
|
||||||
|
### Payment processing fails?
|
||||||
|
1. Verify all Square credentials are correct
|
||||||
|
2. Ensure `SQUARE_ENVIRONMENT` matches your token type
|
||||||
|
3. Check backend logs for detailed errors
|
||||||
|
|
||||||
|
### Can't see payment form?
|
||||||
|
Check browser console - Square SDK must load successfully.
|
||||||
|
|
||||||
|
## 🎓 Going Live
|
||||||
|
|
||||||
|
When ready for production payments:
|
||||||
|
|
||||||
|
1. ✅ Get Square production credentials
|
||||||
|
2. ✅ Update `.env` with production values
|
||||||
|
3. ✅ Change `SQUARE_ENVIRONMENT=production`
|
||||||
|
4. ✅ Update Square SDK URL in `index.html` to production
|
||||||
|
5. ✅ Test thoroughly with real cards (can be refunded)
|
||||||
|
6. ✅ Monitor Square Dashboard and application logs
|
||||||
|
|
||||||
|
## 💡 Tips
|
||||||
|
|
||||||
|
- **Always test in sandbox first** - No risk, unlimited testing
|
||||||
|
- **Keep credentials secure** - Never commit `.env` to git
|
||||||
|
- **Monitor transactions** - Check Square Dashboard regularly
|
||||||
|
- **Test refunds** - Make sure admin refund flow works
|
||||||
|
- **Email notifications** - Verify users receive payment confirmations
|
||||||
|
|
||||||
|
## 🆘 Need Help?
|
||||||
|
|
||||||
|
1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions
|
||||||
|
2. Review Square's documentation
|
||||||
|
3. Check application logs: `docker-compose logs -f backend`
|
||||||
|
4. Contact Square support for payment-specific issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to accept payments?** Just follow Steps 1-4 above! 🚀
|
||||||
118
backend/alembic.ini
Normal file
118
backend/alembic.ini
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# Database URL - will be overridden by environment variables in production
|
||||||
|
# sqlalchemy.url = mysql+pymysql://username:password@host:port/database
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
97
backend/alembic/env.py
Normal file
97
backend/alembic/env.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
from app.models.models import Base
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Set database URL from environment variables if available
|
||||||
|
def get_database_url():
|
||||||
|
"""Get database URL from environment variables or config"""
|
||||||
|
# Try to get from environment variables first
|
||||||
|
db_host = os.getenv("DATABASE_HOST")
|
||||||
|
db_port = os.getenv("DATABASE_PORT", "3306")
|
||||||
|
db_user = os.getenv("DATABASE_USER")
|
||||||
|
db_password = os.getenv("DATABASE_PASSWORD")
|
||||||
|
db_name = os.getenv("DATABASE_NAME")
|
||||||
|
|
||||||
|
if all([db_host, db_user, db_password, db_name]):
|
||||||
|
return f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||||
|
|
||||||
|
# Fallback to config file
|
||||||
|
return config.get_main_option("sqlalchemy.url")
|
||||||
|
|
||||||
|
# Set the database URL
|
||||||
|
config.set_main_option("sqlalchemy.url", get_database_url())
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = get_database_url()
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Create initial database schema from scratch
|
||||||
|
|
||||||
|
Revision ID: b583fd2cf202
|
||||||
|
Revises: f9fbfa70654e
|
||||||
|
Create Date: 2025-11-22 21:02:08.977255
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b583fd2cf202'
|
||||||
|
down_revision: Union[str, None] = 'f9fbfa70654e'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create tables in dependency order (no foreign keys first, then with foreign keys)
|
||||||
|
|
||||||
|
# Users table (no dependencies)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('first_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('last_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('phone', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('address', sa.Text(), nullable=True),
|
||||||
|
sa.Column('role', sa.Enum('member', 'admin', 'super_admin', name='userrole'), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||||
|
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Membership tiers table (no dependencies)
|
||||||
|
op.create_table('membership_tiers',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('annual_fee', sa.Float(), nullable=False),
|
||||||
|
sa.Column('benefits', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_membership_tiers_id'), 'membership_tiers', ['id'], unique=False)
|
||||||
|
op.create_index('ix_membership_tiers_name', 'membership_tiers', ['name'], unique=True)
|
||||||
|
|
||||||
|
# Volunteer roles table (no dependencies)
|
||||||
|
op.create_table('volunteer_roles',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_volunteer_roles_id'), 'volunteer_roles', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Email templates table (no dependencies)
|
||||||
|
op.create_table('email_templates',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('template_key', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('subject', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('html_body', sa.Text(), nullable=False),
|
||||||
|
sa.Column('text_body', sa.Text(), nullable=True),
|
||||||
|
sa.Column('variables', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_email_templates_id'), 'email_templates', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_email_templates_template_key'), 'email_templates', ['template_key'], unique=True)
|
||||||
|
|
||||||
|
# Email bounces table (no dependencies)
|
||||||
|
op.create_table('email_bounces',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bounce_type', sa.Enum('hard', 'soft', 'complaint', 'unsubscribe', name='bouncetype'), nullable=False),
|
||||||
|
sa.Column('bounce_reason', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('smtp2go_message_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('bounce_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_email_bounces_email'), 'email_bounces', ['email'], unique=False)
|
||||||
|
op.create_index(op.f('ix_email_bounces_id'), 'email_bounces', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_email_bounces_smtp2go_message_id'), 'email_bounces', ['smtp2go_message_id'], unique=False)
|
||||||
|
|
||||||
|
# Memberships table (depends on users, membership_tiers)
|
||||||
|
op.create_table('memberships',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('active', 'expired', 'pending', 'cancelled', name='membershipstatus'), nullable=False),
|
||||||
|
sa.Column('start_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('end_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('auto_renew', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tier_id'], ['membership_tiers.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_memberships_id'), 'memberships', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Payments table (depends on users, memberships)
|
||||||
|
op.create_table('payments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('membership_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('amount', sa.Float(), nullable=False),
|
||||||
|
sa.Column('payment_method', sa.Enum('square', 'cash', 'check', 'dummy', name='paymentmethod'), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('pending', 'completed', 'failed', 'refunded', name='paymentstatus'), nullable=False),
|
||||||
|
sa.Column('transaction_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('payment_date', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['membership_id'], ['memberships.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Events table (depends on users)
|
||||||
|
op.create_table('events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('event_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('event_time', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('location', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('max_attendees', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status', sa.Enum('draft', 'published', 'cancelled', 'completed', name='eventstatus'), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_events_id'), 'events', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Event RSVPs table (depends on events, users)
|
||||||
|
op.create_table('event_rsvps',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('pending', 'attending', 'not_attending', 'maybe', name='rsvpstatus'), nullable=False),
|
||||||
|
sa.Column('attended', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_event_rsvps_id'), 'event_rsvps', ['id'], unique=False)
|
||||||
|
op.create_index('ix_event_rsvps_event_id_user_id', 'event_rsvps', ['event_id', 'user_id'], unique=True)
|
||||||
|
|
||||||
|
# Volunteer assignments table (depends on users, volunteer_roles)
|
||||||
|
op.create_table('volunteer_assignments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assigned_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['volunteer_roles.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_volunteer_assignments_id'), 'volunteer_assignments', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Volunteer schedules table (depends on volunteer_assignments)
|
||||||
|
op.create_table('volunteer_schedules',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('schedule_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('start_time', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('end_time', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('location', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('completed', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['assignment_id'], ['volunteer_assignments.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_volunteer_schedules_id'), 'volunteer_schedules', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Certificates table (depends on users)
|
||||||
|
op.create_table('certificates',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('certificate_name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('issuing_organization', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('issue_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('expiry_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('certificate_number', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('file_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_certificates_id'), 'certificates', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Files table (depends on users, membership_tiers)
|
||||||
|
op.create_table('files',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('file_path', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('file_size', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('mime_type', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('min_tier_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('uploaded_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['min_tier_id'], ['membership_tiers.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Notifications table (depends on users)
|
||||||
|
op.create_table('notifications',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('subject', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('message', sa.Text(), nullable=False),
|
||||||
|
sa.Column('email_sent', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('sent_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Password reset tokens table (depends on users)
|
||||||
|
op.create_table('password_reset_tokens',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('token', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('used', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop tables in reverse dependency order
|
||||||
|
op.drop_table('password_reset_tokens')
|
||||||
|
op.drop_table('notifications')
|
||||||
|
op.drop_table('files')
|
||||||
|
op.drop_table('certificates')
|
||||||
|
op.drop_table('volunteer_schedules')
|
||||||
|
op.drop_table('volunteer_assignments')
|
||||||
|
op.drop_table('event_rsvps')
|
||||||
|
op.drop_table('events')
|
||||||
|
op.drop_table('payments')
|
||||||
|
op.drop_table('memberships')
|
||||||
|
op.drop_table('email_bounces')
|
||||||
|
op.drop_table('email_templates')
|
||||||
|
op.drop_table('volunteer_roles')
|
||||||
|
op.drop_table('membership_tiers')
|
||||||
|
op.drop_table('users')
|
||||||
|
|
||||||
|
# Drop enums
|
||||||
|
op.execute("DROP TYPE IF EXISTS userrole")
|
||||||
|
op.execute("DROP TYPE IF EXISTS membershipstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS paymentmethod")
|
||||||
|
op.execute("DROP TYPE IF EXISTS paymentstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS eventstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS rsvpstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS bouncetype")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Initial baseline migration
|
||||||
|
|
||||||
|
Revision ID: f9fbfa70654e
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-11-22 20:53:20.604227
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f9fbfa70654e'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from . import auth, users, tiers, memberships, payments, email, email_templates
|
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -10,3 +10,5 @@ api_router.include_router(memberships.router, prefix="/memberships", tags=["memb
|
|||||||
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
|
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
|
||||||
api_router.include_router(email.router, prefix="/email", tags=["email"])
|
api_router.include_router(email.router, prefix="/email", tags=["email"])
|
||||||
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
||||||
|
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
||||||
|
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
|
||||||
|
|||||||
207
backend/app/api/v1/events.py
Normal file
207
backend/app/api/v1/events.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ...core.database import get_db
|
||||||
|
from ...models.models import Event, EventRSVP, User, EventStatus
|
||||||
|
from ...schemas import (
|
||||||
|
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
|
||||||
|
)
|
||||||
|
from ...api.dependencies import get_current_active_user, get_admin_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[EventResponse])
|
||||||
|
async def get_events(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all events (admin) or published events (members)"""
|
||||||
|
if current_user.role in ['admin', 'super_admin']:
|
||||||
|
events = db.query(Event).order_by(Event.event_date).all()
|
||||||
|
else:
|
||||||
|
events = db.query(Event).filter(
|
||||||
|
Event.status == EventStatus.PUBLISHED
|
||||||
|
).order_by(Event.event_date).all()
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/upcoming", response_model=List[EventResponse])
|
||||||
|
async def get_upcoming_events(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get upcoming events"""
|
||||||
|
now = datetime.now()
|
||||||
|
events = db.query(Event).filter(
|
||||||
|
Event.event_date >= now.date(),
|
||||||
|
Event.status == EventStatus.PUBLISHED
|
||||||
|
).order_by(Event.event_date).all()
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=EventResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_event(
|
||||||
|
event_data: EventCreate,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a new event (admin only)"""
|
||||||
|
# Validate event date is in the future
|
||||||
|
if event_data.event_date < datetime.now():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Event date must be in the future"
|
||||||
|
)
|
||||||
|
|
||||||
|
event = Event(
|
||||||
|
title=event_data.title,
|
||||||
|
description=event_data.description,
|
||||||
|
event_date=event_data.event_date,
|
||||||
|
event_time=event_data.event_time,
|
||||||
|
location=event_data.location,
|
||||||
|
max_attendees=event_data.max_attendees,
|
||||||
|
status=EventStatus.DRAFT,
|
||||||
|
created_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{event_id}", response_model=EventResponse)
|
||||||
|
async def update_event(
|
||||||
|
event_id: int,
|
||||||
|
event_data: EventUpdate,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update an event (admin only)"""
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Event not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for field, value in event_data.dict(exclude_unset=True).items():
|
||||||
|
setattr(event, field, value)
|
||||||
|
|
||||||
|
event.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{event_id}", response_model=MessageResponse)
|
||||||
|
async def delete_event(
|
||||||
|
event_id: int,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete an event (admin only)"""
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Event not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(event)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Event deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_id}/rsvps", response_model=List[EventRSVPResponse])
|
||||||
|
async def get_event_rsvps(
|
||||||
|
event_id: int,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get RSVPs for an event (admin only)"""
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Event not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all()
|
||||||
|
return rsvps
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/rsvp", response_model=EventRSVPResponse)
|
||||||
|
async def create_or_update_rsvp(
|
||||||
|
event_id: int,
|
||||||
|
rsvp_data: EventRSVPUpdate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create or update RSVP for an event"""
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Event not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.status != EventStatus.PUBLISHED:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Event is not available for RSVP"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if RSVP already exists
|
||||||
|
existing_rsvp = db.query(EventRSVP).filter(
|
||||||
|
EventRSVP.event_id == event_id,
|
||||||
|
EventRSVP.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_rsvp:
|
||||||
|
# Update existing RSVP
|
||||||
|
existing_rsvp.status = rsvp_data.status
|
||||||
|
if rsvp_data.notes is not None:
|
||||||
|
existing_rsvp.notes = rsvp_data.notes
|
||||||
|
existing_rsvp.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_rsvp)
|
||||||
|
return existing_rsvp
|
||||||
|
else:
|
||||||
|
# Check attendee limit
|
||||||
|
if event.max_attendees:
|
||||||
|
current_rsvp_count = db.query(EventRSVP).filter(
|
||||||
|
EventRSVP.event_id == event_id,
|
||||||
|
EventRSVP.status == 'attending'
|
||||||
|
).count()
|
||||||
|
if current_rsvp_count >= event.max_attendees:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Event is at maximum capacity"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new RSVP
|
||||||
|
rsvp = EventRSVP(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
status=rsvp_data.status,
|
||||||
|
notes=rsvp_data.notes
|
||||||
|
)
|
||||||
|
db.add(rsvp)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rsvp)
|
||||||
|
return rsvp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-rsvps", response_model=List[EventRSVPResponse])
|
||||||
|
async def get_my_rsvps(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get current user's RSVPs"""
|
||||||
|
rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all()
|
||||||
|
return rsvps
|
||||||
47
backend/app/api/v1/feature_flags.py
Normal file
47
backend/app/api/v1/feature_flags.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from typing import Dict, Any
|
||||||
|
from app.services.feature_flag_service import feature_flags
|
||||||
|
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flags", response_model=FeatureFlagsResponse)
|
||||||
|
async def get_all_feature_flags() -> FeatureFlagsResponse:
|
||||||
|
"""
|
||||||
|
Get all feature flags for the frontend
|
||||||
|
This endpoint is public as it only returns feature configuration
|
||||||
|
"""
|
||||||
|
all_flags = feature_flags.get_all_flags()
|
||||||
|
enabled_flags = feature_flags.get_enabled_flags()
|
||||||
|
|
||||||
|
return FeatureFlagsResponse(
|
||||||
|
flags=all_flags,
|
||||||
|
enabled_flags=enabled_flags
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flags/{flag_name}", response_model=FeatureFlagResponse)
|
||||||
|
async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
|
||||||
|
"""
|
||||||
|
Get a specific feature flag value
|
||||||
|
"""
|
||||||
|
flag_name_upper = flag_name.upper()
|
||||||
|
enabled = feature_flags.is_enabled(flag_name_upper)
|
||||||
|
value = feature_flags.get_flag_value(flag_name_upper)
|
||||||
|
|
||||||
|
return FeatureFlagResponse(
|
||||||
|
name=flag_name_upper,
|
||||||
|
enabled=enabled,
|
||||||
|
value=value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/flags/reload")
|
||||||
|
async def reload_feature_flags():
|
||||||
|
"""
|
||||||
|
Reload feature flags from environment variables
|
||||||
|
This could be protected with admin permissions in production
|
||||||
|
"""
|
||||||
|
feature_flags.reload_flags()
|
||||||
|
return {"message": "Feature flags reloaded successfully"}
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...models.models import Payment, PaymentStatus, User, Membership, MembershipStatus
|
from ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse
|
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse,
|
||||||
|
SquarePaymentRequest, SquarePaymentResponse, SquareRefundRequest
|
||||||
)
|
)
|
||||||
from ...api.dependencies import get_current_active_user, get_admin_user
|
from ...api.dependencies import get_current_active_user, get_admin_user
|
||||||
from ...services.email_service import email_service
|
from ...services.email_service import email_service
|
||||||
|
from ...services.square_service import square_service
|
||||||
|
from ...core.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/square")
|
||||||
|
async def get_square_config():
|
||||||
|
"""Get Square configuration for frontend"""
|
||||||
|
return {
|
||||||
|
"application_id": settings.SQUARE_APPLICATION_ID,
|
||||||
|
"location_id": settings.SQUARE_LOCATION_ID,
|
||||||
|
"environment": settings.SQUARE_ENVIRONMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/my-payments", response_model=List[PaymentResponse])
|
@router.get("/my-payments", response_model=List[PaymentResponse])
|
||||||
async def get_my_payments(
|
async def get_my_payments(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
@@ -144,6 +158,182 @@ async def update_payment(
|
|||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/square/process", response_model=SquarePaymentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def process_square_payment(
|
||||||
|
payment_request: SquarePaymentRequest,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Process a Square payment and create membership
|
||||||
|
This endpoint receives a payment token from the Square Web Payments SDK,
|
||||||
|
processes the payment through Square's API, and creates an ACTIVE membership
|
||||||
|
"""
|
||||||
|
# Verify tier exists
|
||||||
|
tier = db.query(MembershipTier).filter(
|
||||||
|
MembershipTier.id == payment_request.tier_id,
|
||||||
|
MembershipTier.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not tier:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Membership tier not found or not active"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a reference ID for tracking
|
||||||
|
reference_id = f"user_{current_user.id}_tier_{tier.id}_{datetime.utcnow().timestamp()}"
|
||||||
|
|
||||||
|
# Process payment with Square
|
||||||
|
square_result = await square_service.create_payment(
|
||||||
|
amount_money=payment_request.amount,
|
||||||
|
source_id=payment_request.source_id,
|
||||||
|
idempotency_key=payment_request.idempotency_key,
|
||||||
|
reference_id=reference_id,
|
||||||
|
note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}",
|
||||||
|
billing_details=payment_request.billing_details
|
||||||
|
)
|
||||||
|
|
||||||
|
if not square_result.get('success'):
|
||||||
|
# Payment failed - don't create membership
|
||||||
|
return SquarePaymentResponse(
|
||||||
|
success=False,
|
||||||
|
errors=square_result.get('errors', ['Payment processing failed'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Payment succeeded - create membership and payment records in a transaction
|
||||||
|
try:
|
||||||
|
# Calculate membership dates
|
||||||
|
start_date = datetime.utcnow().date()
|
||||||
|
end_date = start_date + relativedelta(years=1)
|
||||||
|
|
||||||
|
# Create membership with ACTIVE status
|
||||||
|
membership = Membership(
|
||||||
|
user_id=current_user.id,
|
||||||
|
tier_id=tier.id,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
status=MembershipStatus.ACTIVE
|
||||||
|
)
|
||||||
|
db.add(membership)
|
||||||
|
db.flush() # Get membership ID without committing
|
||||||
|
|
||||||
|
# Create payment record
|
||||||
|
payment = Payment(
|
||||||
|
user_id=current_user.id,
|
||||||
|
membership_id=membership.id,
|
||||||
|
amount=payment_request.amount,
|
||||||
|
payment_method=PaymentMethod.SQUARE,
|
||||||
|
status=PaymentStatus.COMPLETED,
|
||||||
|
transaction_id=square_result.get('payment_id'),
|
||||||
|
payment_date=datetime.utcnow(),
|
||||||
|
notes=payment_request.note
|
||||||
|
)
|
||||||
|
db.add(payment)
|
||||||
|
|
||||||
|
# Commit both together
|
||||||
|
db.commit()
|
||||||
|
db.refresh(membership)
|
||||||
|
db.refresh(payment)
|
||||||
|
|
||||||
|
# Send activation email (non-blocking)
|
||||||
|
try:
|
||||||
|
await email_service.send_membership_activation_email(
|
||||||
|
to_email=current_user.email,
|
||||||
|
first_name=current_user.first_name,
|
||||||
|
membership_tier=tier.name,
|
||||||
|
annual_fee=tier.annual_fee,
|
||||||
|
payment_amount=payment.amount,
|
||||||
|
payment_method="Square",
|
||||||
|
renewal_date=end_date.strftime("%d %B %Y"),
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the payment
|
||||||
|
print(f"Failed to send membership activation email: {e}")
|
||||||
|
|
||||||
|
return SquarePaymentResponse(
|
||||||
|
success=True,
|
||||||
|
payment_id=square_result.get('payment_id'),
|
||||||
|
status=square_result.get('status'),
|
||||||
|
amount=payment_request.amount,
|
||||||
|
currency='GBP',
|
||||||
|
receipt_url=square_result.get('receipt_url'),
|
||||||
|
database_payment_id=payment.id,
|
||||||
|
membership_id=membership.id
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
# Payment succeeded but membership creation failed
|
||||||
|
# This is a critical error - we should log it and potentially refund
|
||||||
|
print(f"CRITICAL: Payment succeeded but membership creation failed: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Payment processed but membership creation failed. Please contact support."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/square/refund", response_model=MessageResponse)
|
||||||
|
async def refund_square_payment(
|
||||||
|
refund_request: SquareRefundRequest,
|
||||||
|
current_user = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Refund a Square payment (admin only)
|
||||||
|
"""
|
||||||
|
# Get the payment from database
|
||||||
|
payment = db.query(Payment).filter(Payment.id == refund_request.payment_id).first()
|
||||||
|
|
||||||
|
if not payment:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Payment not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment.payment_method != PaymentMethod.SQUARE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Can only refund Square payments through this endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment.status != PaymentStatus.COMPLETED:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Can only refund completed payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not payment.transaction_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Payment has no Square transaction ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process refund with Square
|
||||||
|
refund_result = await square_service.refund_payment(
|
||||||
|
payment_id=payment.transaction_id,
|
||||||
|
amount_money=refund_request.amount,
|
||||||
|
reason=refund_request.reason
|
||||||
|
)
|
||||||
|
|
||||||
|
if not refund_result.get('success'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Refund failed: {', '.join(refund_result.get('errors', ['Unknown error']))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update payment status
|
||||||
|
payment.status = PaymentStatus.REFUNDED
|
||||||
|
payment.notes = f"{payment.notes or ''}\nRefunded: {refund_request.reason or 'No reason provided'}"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(
|
||||||
|
message="Payment refunded successfully",
|
||||||
|
detail=f"Refund ID: {refund_result.get('refund_id')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[PaymentResponse])
|
@router.get("/", response_model=List[PaymentResponse])
|
||||||
async def list_payments(
|
async def list_payments(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Settings(BaseSettings):
|
|||||||
SQUARE_ACCESS_TOKEN: str
|
SQUARE_ACCESS_TOKEN: str
|
||||||
SQUARE_ENVIRONMENT: str = "sandbox"
|
SQUARE_ENVIRONMENT: str = "sandbox"
|
||||||
SQUARE_LOCATION_ID: str
|
SQUARE_LOCATION_ID: str
|
||||||
|
SQUARE_APPLICATION_ID: str
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
SMTP2GO_API_KEY: str
|
SMTP2GO_API_KEY: str
|
||||||
@@ -40,7 +41,7 @@ class Settings(BaseSettings):
|
|||||||
FRONTEND_URL: str = "http://localhost:3500"
|
FRONTEND_URL: str = "http://localhost:3500"
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"]
|
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080", "https://members.sasalliance.org"]
|
||||||
|
|
||||||
# File Storage
|
# File Storage
|
||||||
UPLOAD_DIR: str = "/app/uploads"
|
UPLOAD_DIR: str = "/app/uploads"
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
from .api.v1 import api_router
|
from .api.v1 import api_router
|
||||||
|
from .core.database import get_db
|
||||||
|
from .core.init_db import init_default_data
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Handle startup and shutdown events"""
|
||||||
|
# Startup
|
||||||
|
db: Session = next(get_db())
|
||||||
|
try:
|
||||||
|
init_default_data(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown (if needed)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.APP_NAME,
|
title=settings.APP_NAME,
|
||||||
version=settings.APP_VERSION,
|
version=settings.APP_VERSION,
|
||||||
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json"
|
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
|
||||||
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up CORS
|
# Set up CORS
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ class Event(Base):
|
|||||||
title = Column(String(255), nullable=False)
|
title = Column(String(255), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
event_date = Column(DateTime, nullable=False)
|
event_date = Column(DateTime, nullable=False)
|
||||||
|
event_time = Column(String(10), nullable=True) # HH:MM format
|
||||||
location = Column(String(255), nullable=True)
|
location = Column(String(255), nullable=True)
|
||||||
max_attendees = Column(Integer, nullable=True)
|
max_attendees = Column(Integer, nullable=True)
|
||||||
status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False)
|
status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False)
|
||||||
|
|||||||
@@ -22,11 +22,21 @@ from .schemas import (
|
|||||||
PaymentCreate,
|
PaymentCreate,
|
||||||
PaymentUpdate,
|
PaymentUpdate,
|
||||||
PaymentResponse,
|
PaymentResponse,
|
||||||
|
SquarePaymentRequest,
|
||||||
|
SquarePaymentResponse,
|
||||||
|
SquareRefundRequest,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
EmailTemplateBase,
|
EmailTemplateBase,
|
||||||
EmailTemplateCreate,
|
EmailTemplateCreate,
|
||||||
EmailTemplateUpdate,
|
EmailTemplateUpdate,
|
||||||
EmailTemplateResponse,
|
EmailTemplateResponse,
|
||||||
|
EventBase,
|
||||||
|
EventCreate,
|
||||||
|
EventUpdate,
|
||||||
|
EventResponse,
|
||||||
|
EventRSVPBase,
|
||||||
|
EventRSVPUpdate,
|
||||||
|
EventRSVPResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -53,9 +63,19 @@ __all__ = [
|
|||||||
"PaymentCreate",
|
"PaymentCreate",
|
||||||
"PaymentUpdate",
|
"PaymentUpdate",
|
||||||
"PaymentResponse",
|
"PaymentResponse",
|
||||||
|
"SquarePaymentRequest",
|
||||||
|
"SquarePaymentResponse",
|
||||||
|
"SquareRefundRequest",
|
||||||
"MessageResponse",
|
"MessageResponse",
|
||||||
"EmailTemplateBase",
|
"EmailTemplateBase",
|
||||||
"EmailTemplateCreate",
|
"EmailTemplateCreate",
|
||||||
"EmailTemplateUpdate",
|
"EmailTemplateUpdate",
|
||||||
"EmailTemplateResponse",
|
"EmailTemplateResponse",
|
||||||
|
"EventBase",
|
||||||
|
"EventCreate",
|
||||||
|
"EventUpdate",
|
||||||
|
"EventResponse",
|
||||||
|
"EventRSVPBase",
|
||||||
|
"EventRSVPUpdate",
|
||||||
|
"EventRSVPResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
15
backend/app/schemas/feature_flags.py
Normal file
15
backend/app/schemas/feature_flags.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagsResponse(BaseModel):
|
||||||
|
"""Response model for feature flags"""
|
||||||
|
flags: Dict[str, Any]
|
||||||
|
enabled_flags: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagResponse(BaseModel):
|
||||||
|
"""Response model for a single feature flag"""
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
value: Any
|
||||||
@@ -163,6 +163,37 @@ class PaymentResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Square Payment Schemas
|
||||||
|
class SquarePaymentRequest(BaseModel):
|
||||||
|
"""Request schema for Square payment processing"""
|
||||||
|
source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK")
|
||||||
|
tier_id: int = Field(..., description="Membership tier ID to create membership for")
|
||||||
|
amount: float = Field(..., gt=0, description="Payment amount in GBP")
|
||||||
|
idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments")
|
||||||
|
note: Optional[str] = Field(None, description="Optional payment note")
|
||||||
|
billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS")
|
||||||
|
|
||||||
|
|
||||||
|
class SquarePaymentResponse(BaseModel):
|
||||||
|
"""Response schema for Square payment"""
|
||||||
|
success: bool
|
||||||
|
payment_id: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
amount: Optional[float] = None
|
||||||
|
currency: Optional[str] = None
|
||||||
|
receipt_url: Optional[str] = None
|
||||||
|
errors: Optional[list[str]] = None
|
||||||
|
database_payment_id: Optional[int] = None
|
||||||
|
membership_id: Optional[int] = Field(None, description="Created membership ID")
|
||||||
|
|
||||||
|
|
||||||
|
class SquareRefundRequest(BaseModel):
|
||||||
|
"""Request schema for Square payment refund"""
|
||||||
|
payment_id: int = Field(..., description="Database payment ID")
|
||||||
|
amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)")
|
||||||
|
reason: Optional[str] = Field(None, description="Reason for refund")
|
||||||
|
|
||||||
|
|
||||||
# Message Response
|
# Message Response
|
||||||
class MessageResponse(BaseModel):
|
class MessageResponse(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
@@ -199,3 +230,58 @@ class EmailTemplateResponse(EmailTemplateBase):
|
|||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Event Schemas
|
||||||
|
class EventBase(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_date: datetime
|
||||||
|
event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$')
|
||||||
|
location: Optional[str] = None
|
||||||
|
max_attendees: Optional[int] = Field(None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class EventCreate(EventBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EventUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_date: Optional[datetime] = None
|
||||||
|
event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$')
|
||||||
|
location: Optional[str] = None
|
||||||
|
max_attendees: Optional[int] = Field(None, gt=0)
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventResponse(EventBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
status: str
|
||||||
|
created_by: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Event RSVP Schemas
|
||||||
|
class EventRSVPBase(BaseModel):
|
||||||
|
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventRSVPUpdate(EventRSVPBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EventRSVPResponse(EventRSVPBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
event_id: int
|
||||||
|
user_id: int
|
||||||
|
attended: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|||||||
80
backend/app/services/feature_flag_service.py
Normal file
80
backend/app/services/feature_flag_service.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Feature Flag Service for managing application features
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagService:
|
||||||
|
"""Service for managing feature flags"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize feature flags from environment variables"""
|
||||||
|
self._flags = self._load_flags_from_env()
|
||||||
|
|
||||||
|
def _load_flags_from_env(self) -> Dict[str, Any]:
|
||||||
|
"""Load feature flags from environment variables"""
|
||||||
|
# Get the FEATURE_FLAGS environment variable (comma-separated list)
|
||||||
|
feature_flags_env = os.getenv("FEATURE_FLAGS", "")
|
||||||
|
|
||||||
|
# Default feature flags - these can be overridden by environment
|
||||||
|
default_flags = {
|
||||||
|
"CASH_PAYMENT_ENABLED": True,
|
||||||
|
"EMAIL_NOTIFICATIONS_ENABLED": True,
|
||||||
|
"EVENT_MANAGEMENT_ENABLED": True,
|
||||||
|
"AUTO_RENEWAL_ENABLED": False,
|
||||||
|
"MEMBERSHIP_TRANSFERS_ENABLED": False,
|
||||||
|
"BULK_OPERATIONS_ENABLED": False,
|
||||||
|
"ADVANCED_REPORTING_ENABLED": False,
|
||||||
|
"API_RATE_LIMITING_ENABLED": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse environment variable
|
||||||
|
flags = default_flags.copy()
|
||||||
|
|
||||||
|
if feature_flags_env:
|
||||||
|
# Parse comma-separated key=value pairs
|
||||||
|
for flag_pair in feature_flags_env.split(","):
|
||||||
|
flag_pair = flag_pair.strip()
|
||||||
|
if "=" in flag_pair:
|
||||||
|
key, value = flag_pair.split("=", 1)
|
||||||
|
key = key.strip().upper()
|
||||||
|
value = value.strip().lower()
|
||||||
|
|
||||||
|
# Convert string to boolean
|
||||||
|
if value in ("true", "1", "yes", "on"):
|
||||||
|
flags[key] = True
|
||||||
|
elif value in ("false", "0", "no", "off"):
|
||||||
|
flags[key] = False
|
||||||
|
else:
|
||||||
|
# For non-boolean values, keep as string
|
||||||
|
flags[key] = value
|
||||||
|
|
||||||
|
return flags
|
||||||
|
|
||||||
|
def is_enabled(self, flag_name: str) -> bool:
|
||||||
|
"""Check if a feature flag is enabled"""
|
||||||
|
flag_name = flag_name.upper()
|
||||||
|
return bool(self._flags.get(flag_name, False))
|
||||||
|
|
||||||
|
def get_flag_value(self, flag_name: str, default: Any = None) -> Any:
|
||||||
|
"""Get the value of a feature flag"""
|
||||||
|
flag_name = flag_name.upper()
|
||||||
|
return self._flags.get(flag_name, default)
|
||||||
|
|
||||||
|
def get_all_flags(self) -> Dict[str, Any]:
|
||||||
|
"""Get all feature flags"""
|
||||||
|
return self._flags.copy()
|
||||||
|
|
||||||
|
def get_enabled_flags(self) -> List[str]:
|
||||||
|
"""Get list of enabled feature flag names"""
|
||||||
|
return [name for name, value in self._flags.items() if value is True]
|
||||||
|
|
||||||
|
def reload_flags(self) -> None:
|
||||||
|
"""Reload feature flags from environment (useful for runtime updates)"""
|
||||||
|
self._flags = self._load_flags_from_env()
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
feature_flags = FeatureFlagService()
|
||||||
364
backend/app/services/square_service.py
Normal file
364
backend/app/services/square_service.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"""
|
||||||
|
Square Payment Service
|
||||||
|
Handles Square payment processing including payment creation, verification, and webhooks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from square.client import Square, SquareEnvironment
|
||||||
|
from typing import Dict, Optional
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SquareService:
|
||||||
|
"""Service for handling Square payment processing"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Square client"""
|
||||||
|
# Configure Square client based on environment
|
||||||
|
environment = (
|
||||||
|
SquareEnvironment.SANDBOX
|
||||||
|
if settings.SQUARE_ENVIRONMENT.lower() == 'sandbox'
|
||||||
|
else SquareEnvironment.PRODUCTION
|
||||||
|
)
|
||||||
|
self.client = Square(
|
||||||
|
token=settings.SQUARE_ACCESS_TOKEN,
|
||||||
|
environment=environment
|
||||||
|
)
|
||||||
|
self.location_id = settings.SQUARE_LOCATION_ID
|
||||||
|
|
||||||
|
async def create_payment(
|
||||||
|
self,
|
||||||
|
amount_money: float,
|
||||||
|
source_id: str,
|
||||||
|
idempotency_key: Optional[str] = None,
|
||||||
|
customer_id: Optional[str] = None,
|
||||||
|
reference_id: Optional[str] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
billing_details: Optional[Dict] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Create a payment using Square
|
||||||
|
|
||||||
|
Args:
|
||||||
|
amount_money: Amount in the currency's smallest denomination (e.g., cents for GBP)
|
||||||
|
source_id: Payment source ID from the Web Payments SDK
|
||||||
|
idempotency_key: Unique key to prevent duplicate payments
|
||||||
|
customer_id: Optional Square customer ID
|
||||||
|
reference_id: Optional reference ID for internal tracking
|
||||||
|
note: Optional note about the payment
|
||||||
|
billing_details: Optional billing address and cardholder name for AVS
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with payment result including payment_id, status, and details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Generate idempotency key if not provided
|
||||||
|
if not idempotency_key:
|
||||||
|
idempotency_key = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Convert amount to money object (Square expects amount in smallest currency unit)
|
||||||
|
# For GBP, this is pence
|
||||||
|
amount_in_pence = int(amount_money * 100)
|
||||||
|
|
||||||
|
# Build payment parameters
|
||||||
|
payment_params = {
|
||||||
|
'source_id': source_id,
|
||||||
|
'idempotency_key': idempotency_key,
|
||||||
|
'amount_money': {
|
||||||
|
'amount': amount_in_pence,
|
||||||
|
'currency': 'GBP'
|
||||||
|
},
|
||||||
|
'location_id': self.location_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add billing address for AVS if provided
|
||||||
|
if billing_details:
|
||||||
|
# Add buyer email if available
|
||||||
|
if billing_details.get('email'):
|
||||||
|
payment_params['buyer_email_address'] = billing_details.get('email')
|
||||||
|
|
||||||
|
# Build billing address for AVS
|
||||||
|
billing_address = {}
|
||||||
|
if billing_details.get('address_line_1'):
|
||||||
|
billing_address['address_line_1'] = billing_details.get('address_line_1')
|
||||||
|
if billing_details.get('address_line_2'):
|
||||||
|
billing_address['address_line_2'] = billing_details.get('address_line_2')
|
||||||
|
if billing_details.get('city'):
|
||||||
|
billing_address['locality'] = billing_details.get('city')
|
||||||
|
if billing_details.get('postal_code'):
|
||||||
|
billing_address['postal_code'] = billing_details.get('postal_code')
|
||||||
|
if billing_details.get('country'):
|
||||||
|
billing_address['country'] = billing_details.get('country')
|
||||||
|
|
||||||
|
if billing_address:
|
||||||
|
payment_params['billing_address'] = billing_address
|
||||||
|
|
||||||
|
# Add optional parameters
|
||||||
|
if customer_id:
|
||||||
|
payment_params['customer_id'] = customer_id
|
||||||
|
if reference_id:
|
||||||
|
payment_params['reference_id'] = reference_id
|
||||||
|
if note:
|
||||||
|
payment_params['note'] = note
|
||||||
|
|
||||||
|
# Create payment - pass parameters directly as keyword arguments
|
||||||
|
result = self.client.payments.create(**payment_params)
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
# Payment failed - extract user-friendly error messages
|
||||||
|
error_messages = []
|
||||||
|
for error in result.errors:
|
||||||
|
code = error.code if hasattr(error, 'code') else None
|
||||||
|
detail = error.detail if hasattr(error, 'detail') else str(error)
|
||||||
|
|
||||||
|
# Map Square error codes to user-friendly messages
|
||||||
|
if code == 'GENERIC_DECLINE':
|
||||||
|
error_messages.append('Your card was declined. Please try a different payment method.')
|
||||||
|
elif code == 'CVV_FAILURE':
|
||||||
|
error_messages.append('The CVV code is invalid. Please check and try again.')
|
||||||
|
elif code == 'INVALID_CARD':
|
||||||
|
error_messages.append('The card information is invalid. Please check your card details.')
|
||||||
|
elif code == 'CARD_DECLINED':
|
||||||
|
error_messages.append('Your card was declined. Please contact your bank or try a different card.')
|
||||||
|
elif code == 'INSUFFICIENT_FUNDS':
|
||||||
|
error_messages.append('Insufficient funds. Please try a different payment method.')
|
||||||
|
elif code == 'INVALID_EXPIRATION':
|
||||||
|
error_messages.append('The card expiration date is invalid.')
|
||||||
|
elif code == 'ADDRESS_VERIFICATION_FAILURE':
|
||||||
|
error_messages.append('Address verification failed. Please check your billing address.')
|
||||||
|
else:
|
||||||
|
# For other errors, use the detail message but clean it up
|
||||||
|
if 'Authorization error' in detail:
|
||||||
|
error_messages.append('Payment authorization failed. Please try a different card.')
|
||||||
|
else:
|
||||||
|
error_messages.append(detail)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': error_messages if error_messages else ['Payment processing failed. Please try again.']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Payment succeeded
|
||||||
|
payment = result.payment
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'payment_id': payment.id,
|
||||||
|
'status': payment.status,
|
||||||
|
'amount': amount_money,
|
||||||
|
'currency': 'GBP',
|
||||||
|
'created_at': payment.created_at,
|
||||||
|
'receipt_url': payment.receipt_url if hasattr(payment, 'receipt_url') else None,
|
||||||
|
'reference_id': reference_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Handle Square API exceptions
|
||||||
|
error_message = str(e)
|
||||||
|
|
||||||
|
# Check if this is a Square API error with response body
|
||||||
|
if hasattr(e, 'errors') and e.errors:
|
||||||
|
# Extract user-friendly messages from Square error
|
||||||
|
friendly_errors = []
|
||||||
|
for error in e.errors:
|
||||||
|
code = error.get('code') if isinstance(error, dict) else getattr(error, 'code', None)
|
||||||
|
|
||||||
|
if code == 'GENERIC_DECLINE':
|
||||||
|
friendly_errors.append('Your card was declined. Please try a different payment method.')
|
||||||
|
elif code == 'INSUFFICIENT_FUNDS':
|
||||||
|
friendly_errors.append('Insufficient funds. Please try a different payment method.')
|
||||||
|
elif code == 'CVV_FAILURE':
|
||||||
|
friendly_errors.append('The CVV code is invalid. Please check and try again.')
|
||||||
|
elif code == 'CARD_DECLINED':
|
||||||
|
friendly_errors.append('Your card was declined. Please contact your bank or try a different card.')
|
||||||
|
else:
|
||||||
|
detail = error.get('detail') if isinstance(error, dict) else getattr(error, 'detail', str(error))
|
||||||
|
if 'Authorization error' in str(detail):
|
||||||
|
friendly_errors.append('Payment authorization failed. Please try a different card.')
|
||||||
|
else:
|
||||||
|
friendly_errors.append(str(detail))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': friendly_errors if friendly_errors else ['Payment processing failed. Please try again.']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generic error fallback
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': ['Payment processing failed. Please try again or contact support.']
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_payment(self, payment_id: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Retrieve payment details from Square
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_id: Square payment ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with payment details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = self.client.payments.get(payment_id)
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': [error.detail if hasattr(error, 'detail') else str(error) for error in result.errors]
|
||||||
|
}
|
||||||
|
|
||||||
|
payment = result.payment
|
||||||
|
amount_money = payment.amount_money
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'payment_id': payment.id,
|
||||||
|
'status': payment.status,
|
||||||
|
'amount': amount_money.amount / 100, # Convert pence to pounds
|
||||||
|
'currency': amount_money.currency,
|
||||||
|
'created_at': payment.created_at,
|
||||||
|
'receipt_url': payment.receipt_url if hasattr(payment, 'receipt_url') else None,
|
||||||
|
'reference_id': payment.reference_id if hasattr(payment, 'reference_id') else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': [str(e)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def refund_payment(
|
||||||
|
self,
|
||||||
|
payment_id: str,
|
||||||
|
amount_money: Optional[float] = None,
|
||||||
|
reason: Optional[str] = None,
|
||||||
|
idempotency_key: Optional[str] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Refund a payment
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_id: Square payment ID to refund
|
||||||
|
amount_money: Amount to refund (None for full refund)
|
||||||
|
reason: Reason for the refund
|
||||||
|
idempotency_key: Unique key to prevent duplicate refunds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with refund result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not idempotency_key:
|
||||||
|
idempotency_key = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Prepare parameters
|
||||||
|
amount_in_pence = int(amount_money * 100) if amount_money else None
|
||||||
|
|
||||||
|
# Create refund - pass parameters directly as keyword arguments
|
||||||
|
if amount_in_pence:
|
||||||
|
result = self.client.refunds.refund_payment(
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
amount_money={
|
||||||
|
'amount': amount_in_pence,
|
||||||
|
'currency': 'GBP'
|
||||||
|
},
|
||||||
|
payment_id=payment_id,
|
||||||
|
reason=reason if reason else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Full refund - get payment amount first
|
||||||
|
payment_result = await self.get_payment(payment_id)
|
||||||
|
if not payment_result.get('success'):
|
||||||
|
return payment_result
|
||||||
|
|
||||||
|
amount_in_pence = int(payment_result['amount'] * 100)
|
||||||
|
result = self.client.refunds.refund_payment(
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
amount_money={
|
||||||
|
'amount': amount_in_pence,
|
||||||
|
'currency': 'GBP'
|
||||||
|
},
|
||||||
|
payment_id=payment_id,
|
||||||
|
reason=reason if reason else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': [error.detail if hasattr(error, 'detail') else str(error) for error in result.errors]
|
||||||
|
}
|
||||||
|
|
||||||
|
refund = result.refund
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'refund_id': refund.id,
|
||||||
|
'status': refund.status,
|
||||||
|
'payment_id': payment_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': [str(e)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_customer(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
given_name: str,
|
||||||
|
family_name: str,
|
||||||
|
phone_number: Optional[str] = None,
|
||||||
|
address: Optional[Dict] = None,
|
||||||
|
idempotency_key: Optional[str] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Create a Square customer for future payments
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Customer email
|
||||||
|
given_name: Customer first name
|
||||||
|
family_name: Customer last name
|
||||||
|
phone_number: Optional phone number
|
||||||
|
address: Optional address dict
|
||||||
|
idempotency_key: Unique key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with customer details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not idempotency_key:
|
||||||
|
idempotency_key = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create customer - pass parameters directly as keyword arguments
|
||||||
|
result = self.client.customers.create(
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
email_address=email,
|
||||||
|
given_name=given_name,
|
||||||
|
family_name=family_name,
|
||||||
|
phone_number=phone_number if phone_number else None,
|
||||||
|
address=address if address else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': [error.detail if hasattr(error, 'detail') else str(error) for error in result.errors]
|
||||||
|
}
|
||||||
|
|
||||||
|
customer = result.customer
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'customer_id': customer.id,
|
||||||
|
'email': customer.email_address if hasattr(customer, 'email_address') else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'errors': [str(e)]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
square_service = SquareService()
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# FastAPI and web server
|
# FastAPI and web server
|
||||||
fastapi==0.104.1
|
fastapi==0.104.1
|
||||||
uvicorn[standard]==0.24.0
|
uvicorn[standard]==0.24.0
|
||||||
pydantic==2.5.0
|
pydantic==2.10.3
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.6.1
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
@@ -17,8 +17,8 @@ passlib[bcrypt]==1.7.4
|
|||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
bcrypt==4.1.1
|
bcrypt==4.1.1
|
||||||
|
|
||||||
# Payment Integration (to be added later)
|
# Payment Integration
|
||||||
# squareup==43.2.0.20251016
|
squareup==43.2.0.20251016
|
||||||
|
|
||||||
# Email Service
|
# Email Service
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
@@ -27,3 +27,4 @@ httpx==0.25.2
|
|||||||
email-validator==2.1.0
|
email-validator==2.1.0
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
-- Initialize database with complete schema and default data
|
|
||||||
-- Set character set to UTF-8 to prevent encoding issues
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
SET CHARACTER SET utf8mb4;
|
|
||||||
|
|
||||||
-- Create all tables
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
hashed_password VARCHAR(255) NOT NULL,
|
|
||||||
first_name VARCHAR(100) NOT NULL,
|
|
||||||
last_name VARCHAR(100) NOT NULL,
|
|
||||||
phone VARCHAR(20),
|
|
||||||
address TEXT,
|
|
||||||
role ENUM('member', 'admin', 'super_admin') NOT NULL DEFAULT 'member',
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
last_login TIMESTAMP NULL,
|
|
||||||
INDEX idx_email (email)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE membership_tiers (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
annual_fee DECIMAL(10,2) NOT NULL,
|
|
||||||
benefits TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE memberships (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
tier_id INT NOT NULL,
|
|
||||||
status ENUM('active', 'expired', 'pending', 'cancelled') NOT NULL DEFAULT 'pending',
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (tier_id) REFERENCES membership_tiers(id),
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_tier_id (tier_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE payments (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
membership_id INT,
|
|
||||||
amount DECIMAL(10,2) NOT NULL,
|
|
||||||
payment_method ENUM('square', 'cash', 'check', 'dummy') NOT NULL,
|
|
||||||
status ENUM('pending', 'completed', 'failed', 'refunded') NOT NULL DEFAULT 'pending',
|
|
||||||
transaction_id VARCHAR(255),
|
|
||||||
payment_date TIMESTAMP NULL,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (membership_id) REFERENCES memberships(id),
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_membership_id (membership_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE events (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
event_date TIMESTAMP NOT NULL,
|
|
||||||
location VARCHAR(255),
|
|
||||||
max_attendees INT,
|
|
||||||
status ENUM('draft', 'published', 'cancelled', 'completed') NOT NULL DEFAULT 'draft',
|
|
||||||
created_by INT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id),
|
|
||||||
INDEX idx_created_by (created_by),
|
|
||||||
INDEX idx_event_date (event_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE event_rsvps (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
event_id INT NOT NULL,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
status ENUM('pending', 'attending', 'not_attending', 'maybe') NOT NULL DEFAULT 'pending',
|
|
||||||
attended BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE KEY unique_event_user (event_id, user_id),
|
|
||||||
INDEX idx_event_id (event_id),
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE volunteer_roles (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE volunteer_assignments (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
role_id INT NOT NULL,
|
|
||||||
assigned_date DATE NOT NULL,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (role_id) REFERENCES volunteer_roles(id),
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_role_id (role_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE volunteer_schedules (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
assignment_id INT NOT NULL,
|
|
||||||
schedule_date DATE NOT NULL,
|
|
||||||
start_time TIMESTAMP NOT NULL,
|
|
||||||
end_time TIMESTAMP NOT NULL,
|
|
||||||
location VARCHAR(255),
|
|
||||||
notes TEXT,
|
|
||||||
completed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (assignment_id) REFERENCES volunteer_assignments(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_assignment_id (assignment_id),
|
|
||||||
INDEX idx_schedule_date (schedule_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE certificates (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
certificate_name VARCHAR(255) NOT NULL,
|
|
||||||
issuing_organization VARCHAR(255),
|
|
||||||
issue_date DATE NOT NULL,
|
|
||||||
expiry_date DATE,
|
|
||||||
certificate_number VARCHAR(100),
|
|
||||||
file_path VARCHAR(500),
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE files (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) NOT NULL,
|
|
||||||
original_filename VARCHAR(255) NOT NULL,
|
|
||||||
file_path VARCHAR(500) NOT NULL,
|
|
||||||
file_size INT NOT NULL,
|
|
||||||
mime_type VARCHAR(100) NOT NULL,
|
|
||||||
min_tier_id INT,
|
|
||||||
description TEXT,
|
|
||||||
uploaded_by INT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (min_tier_id) REFERENCES membership_tiers(id),
|
|
||||||
FOREIGN KEY (uploaded_by) REFERENCES users(id),
|
|
||||||
INDEX idx_min_tier_id (min_tier_id),
|
|
||||||
INDEX idx_uploaded_by (uploaded_by)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE notifications (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
subject VARCHAR(255) NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
sent_at TIMESTAMP NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE password_reset_tokens (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
token VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_token (token),
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE email_templates (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
template_key VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
subject VARCHAR(255) NOT NULL,
|
|
||||||
html_body TEXT NOT NULL,
|
|
||||||
text_body TEXT,
|
|
||||||
variables TEXT, -- JSON string of available variables
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_template_key (template_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE email_bounces (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
bounce_type ENUM('hard', 'soft', 'complaint', 'unsubscribe') NOT NULL,
|
|
||||||
bounce_reason VARCHAR(500),
|
|
||||||
smtp2go_message_id VARCHAR(255),
|
|
||||||
bounce_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_email (email),
|
|
||||||
INDEX idx_smtp2go_message_id (smtp2go_message_id),
|
|
||||||
INDEX idx_active (is_active)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Seed initial data
|
|
||||||
|
|
||||||
-- Create default membership tiers
|
|
||||||
INSERT INTO membership_tiers (name, description, annual_fee, benefits, is_active, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
('Personal', 'Basic membership for individual members', 5.00, 'Access to member portal, meeting notifications, event participation', TRUE, NOW(), NOW()),
|
|
||||||
('Aircraft Owners', 'Group membership for aircraft owners', 25.00, 'All Personal benefits plus priority event registration, aircraft owner resources', TRUE, NOW(), NOW()),
|
|
||||||
('Corporate', 'Corporate membership for businesses', 100.00, 'All benefits plus corporate recognition, promotional opportunities, file access', TRUE, NOW(), NOW());
|
|
||||||
|
|
||||||
-- Create default admin user (password: admin123)
|
|
||||||
INSERT INTO users (email, hashed_password, first_name, last_name, role, is_active, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
('admin@swanseaairport.org', '$2b$12$eeuS7kW4xUYZPLx4LgBGaeoGhPu/cg/9M3WEanWToTwtLOLppJmzq', 'System', 'Administrator', 'super_admin', TRUE, NOW(), NOW());
|
|
||||||
|
|
||||||
-- Create default email templates
|
|
||||||
INSERT INTO email_templates (template_key, name, subject, html_body, text_body, variables, is_active, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
('welcome', 'Welcome Email', 'Welcome to Swansea Airport Stakeholders Alliance - Your Account is Ready',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Thank you for registering with us. Your account has been successfully created.</p><p>You can now:</p><ul><li>Browse membership tiers and select one that suits you</li><li>View upcoming events and meetings</li><li>Access your membership portal</li></ul><p>If you have any questions, please don\'t hesitate to contact us.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nThank you for registering with us. Your account has been successfully created.\n\nYou can now:\n- Browse membership tiers and select one that suits you\n- View upcoming events and meetings\n- Access your membership portal\n\nIf you have any questions, please don\'t hesitate to contact us.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('payment_confirmation', 'Payment Confirmation', 'Payment Confirmation - Swansea Airport Stakeholders Alliance',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Payment Confirmed!</h2><p>Hello {first_name},</p><p>We have received your payment. Thank you!</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Amount:</strong> {amount}</p><p style="margin: 5px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p></div><p>Your membership is now active. You can access all the benefits associated with your tier.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Payment Confirmed!\n\nHello {first_name},\n\nWe have received your payment. Thank you!\n\nAmount: {amount}\nPayment Method: {payment_method}\nMembership Tier: {membership_tier}\n\nYour membership is now active. You can access all the benefits associated with your tier.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name", "amount", "payment_method", "membership_tier"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('membership_activation', 'Membership Activation', 'Your Swansea Airport Stakeholders Alliance Membership is Now Active!',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p><div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;"><h3 style="margin-top: 0; color: #28a745;">Membership Details</h3><p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 8px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p></div><div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;"><h3 style="margin-top: 0; color: #495057;">Payment Information</h3><p style="margin: 8px 0;"><strong>Amount Paid:</strong> {payment_amount}</p><p style="margin: 8px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 8px 0;"><strong>Payment Date:</strong> {payment_date}</p></div><p>Your membership will automatically renew on <strong>{renewal_date}</strong> unless you choose to cancel it. You can manage your membership settings in your account dashboard.</p><p>If you have any questions about your membership or need assistance, please don\'t hesitate to contact us.</p><p>Welcome to the Swansea Airport Stakeholders Alliance community!</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance Team</strong></p></body></html>',
|
|
||||||
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nGreat news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.\n\nMEMBERSHIP DETAILS\n------------------\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nNext Renewal Date: {renewal_date}\n\nPAYMENT INFORMATION\n-------------------\nAmount Paid: {payment_amount}\nPayment Method: {payment_method}\nPayment Date: {payment_date}\n\nYour membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.\n\nIf you have any questions about your membership or need assistance, please don\'t hesitate to contact us.\n\nWelcome to the Swansea Airport Stakeholders Alliance community!\n\nBest regards,\nSwansea Airport Stakeholders Alliance Team',
|
|
||||||
'["first_name", "membership_tier", "annual_fee", "payment_amount", "payment_method", "renewal_date", "payment_date"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('renewal_reminder', 'Renewal Reminder', 'Membership Renewal Reminder - Swansea Airport Stakeholders Alliance',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Membership Renewal Reminder</h2><p>Hello {first_name},</p><p>This is a friendly reminder that your <strong>{membership_tier}</strong> membership will expire on <strong>{expiry_date}</strong>.</p><p>To continue enjoying your membership benefits, please renew your membership.</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 5px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p></div><p>Please log in to your account to renew your membership.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Membership Renewal Reminder\n\nHello {first_name},\n\nThis is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.\n\nTo continue enjoying your membership benefits, please renew your membership.\n\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nExpires: {expiry_date}\n\nPlease log in to your account to renew your membership.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name", "expiry_date", "membership_tier", "annual_fee"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('password_reset', 'Password Reset', 'Password Reset Request - Swansea Airport Stakeholders Alliance Member Portal',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Password Reset Request</h2><p>Hello {first_name},</p><p>You have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.</p><p>Please click the button below to reset your password:</p><div style="text-align: center; margin: 30px 0;"><a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a></div><p>If the button doesn\'t work, you can copy and paste this link into your browser:</p><p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p><p><strong>This link will expire in 1 hour.</strong></p><p>If you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.</p><p>For security reasons, please don\'t share this email with anyone.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Password Reset Request\n\nHello {first_name},\n\nYou have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.\n\nPlease use this link to reset your password: {reset_url}\n\nThis link will expire in 1 hour.\n\nIf you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.\n\nFor security reasons, please don\'t share this email with anyone.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name", "reset_url"]', TRUE, NOW(), NOW());
|
|
||||||
@@ -1,54 +1,69 @@
|
|||||||
services:
|
services:
|
||||||
mysql:
|
# mysql:
|
||||||
image: mysql:8.0
|
# image: mysql:8.0
|
||||||
container_name: membership_mysql
|
# container_name: membership_mysql
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
environment:
|
# environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-rootpassword}
|
# MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-secure_root_password_change_this}
|
||||||
MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
|
# MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
|
||||||
MYSQL_USER: ${DATABASE_USER:-membership_user}
|
# MYSQL_USER: ${DATABASE_USER:-membership_user}
|
||||||
MYSQL_PASSWORD: ${DATABASE_PASSWORD:-change_this_password}
|
# MYSQL_PASSWORD: ${DATABASE_PASSWORD:-secure_password_change_this}
|
||||||
# No external port exposure - database only accessible on private network
|
# # No external port exposure - database only accessible on private network
|
||||||
expose:
|
# expose:
|
||||||
- "3306"
|
# - "3306"
|
||||||
volumes:
|
# volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
# - mysql_data:/var/lib/mysql
|
||||||
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
# networks:
|
||||||
networks:
|
# - membership_private
|
||||||
- membership_private
|
# healthcheck:
|
||||||
healthcheck:
|
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
# start_period: 10s
|
||||||
timeout: 20s
|
# interval: 5s
|
||||||
retries: 10
|
# timeout: 5s
|
||||||
|
# retries: 10
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: membership_backend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
# Database configuration
|
||||||
|
- DATABASE_HOST=${DATABASE_HOST}
|
||||||
|
- DATABASE_PORT=${DATABASE_PORT}
|
||||||
|
- DATABASE_USER=${DATABASE_USER}
|
||||||
|
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
|
||||||
|
- DATABASE_NAME=${DATABASE_NAME}
|
||||||
|
# Application configuration
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- ALGORITHM=${ALGORITHM}
|
||||||
|
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "6000:8000" # Only expose backend API to host
|
- "6000:8000" # Only expose backend API to host
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
|
- ./backend/alembic:/app/alembic
|
||||||
|
- ./backend/alembic.ini:/app/alembic.ini
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
depends_on:
|
command: >
|
||||||
mysql:
|
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||||
condition: service_healthy
|
# depends_on:
|
||||||
networks:
|
# mysql:
|
||||||
- membership_private # Access to database on private network
|
# condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: development # Default to development
|
target: development
|
||||||
container_name: membership_frontend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- VITE_HOST_CHECK=false
|
- VITE_HOST_CHECK=false
|
||||||
|
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS}
|
||||||
ports:
|
ports:
|
||||||
- "8050:3000" # Expose frontend to host
|
- "8050:3000" # Expose frontend to host
|
||||||
volumes:
|
volumes:
|
||||||
@@ -57,33 +72,22 @@ services:
|
|||||||
- ./frontend/vite.config.ts:/app/vite.config.ts
|
- ./frontend/vite.config.ts:/app/vite.config.ts
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
|
||||||
- membership_private # Access to backend on private network
|
|
||||||
profiles:
|
|
||||||
- dev # Only run in development
|
|
||||||
|
|
||||||
frontend-prod:
|
#frontend-prod:
|
||||||
build:
|
# build:
|
||||||
context: ./frontend
|
# context: ./frontend
|
||||||
dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
target: production
|
# target: production
|
||||||
container_name: membership_frontend_prod
|
# container_name: membership_frontend_prod
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
ports:
|
# ports:
|
||||||
- "8050:80" # Nginx default port
|
# - "8050:80" # Nginx default port
|
||||||
depends_on:
|
# depends_on:
|
||||||
- backend
|
# - backend
|
||||||
networks:
|
# networks:
|
||||||
- membership_private
|
# - membership_private
|
||||||
profiles:
|
|
||||||
- prod # Only run in production
|
|
||||||
|
|
||||||
networks:
|
|
||||||
membership_private:
|
|
||||||
driver: bridge
|
|
||||||
internal: false # Allow outbound internet access for backend
|
|
||||||
# Database is not exposed to host - only accessible within this network
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
# mysql_data:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SASA Membership Portal</title>
|
<title>SASA Membership Portal</title>
|
||||||
|
<!-- Square Web Payments SDK loaded dynamically based on environment -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ body {
|
|||||||
background-color: #5a6268;
|
background-color: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
@@ -79,7 +88,7 @@ body {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
@@ -180,11 +189,87 @@ body {
|
|||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop view: side-by-side layout */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid > .card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make tables responsive */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 600px; /* Ensure minimum width for readability */
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth pages mobile adjustments */
|
||||||
|
.auth-container {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome section mobile adjustments */
|
||||||
|
.welcome-section {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-section h1 {
|
||||||
|
font-size: 1.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form grid mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
form[style*="grid-template-columns"] {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
gap: 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
@@ -255,7 +340,7 @@ body {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
color: #333;
|
color: #333;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
@@ -385,3 +470,176 @@ body {
|
|||||||
border-color: #c82333;
|
border-color: #c82333;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Events Container Styles */
|
||||||
|
.events-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
color: #0066cc;
|
||||||
|
font-size: 18px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-datetime {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-location {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 2px solid #adb5bd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: normal;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn.active {
|
||||||
|
font-weight: bold;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:not(.active) {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn-attending.active {
|
||||||
|
border: 3px solid #28a745;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn-maybe.active {
|
||||||
|
border: 3px solid #ffc107;
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
box-shadow: 0 4px 8px rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn-not-attending.active {
|
||||||
|
border: 3px solid #dc3545;
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status.attending {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status.maybe {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status.not_attending {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments for events */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event modal responsive */
|
||||||
|
.modal-content[style*="600px"] {
|
||||||
|
max-width: 95% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content div[style*="grid-template-columns"] {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import ForgotPassword from './pages/ForgotPassword';
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
@@ -12,6 +13,7 @@ import './App.css';
|
|||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
|
<FeatureFlagProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/login" />} />
|
<Route path="/" element={<Navigate to="/login" />} />
|
||||||
@@ -25,6 +27,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="/bounce-management" element={<BounceManagement />} />
|
<Route path="/bounce-management" element={<BounceManagement />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</FeatureFlagProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
80
frontend/src/components/FeatureFlagStatus.tsx
Normal file
80
frontend/src/components/FeatureFlagStatus.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||||
|
|
||||||
|
const FeatureFlagStatus: React.FC = () => {
|
||||||
|
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flags) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReload = async () => {
|
||||||
|
try {
|
||||||
|
await reloadFlags();
|
||||||
|
console.log('Feature flags reloaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reload feature flags:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginBottom: '20px' }}>
|
||||||
|
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
||||||
|
{Object.entries(flags.flags).map(([name, value]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: '500' }}>
|
||||||
|
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
backgroundColor: value ? '#4CAF50' : '#f44336',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleReload}
|
||||||
|
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
Reload Flags
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
|
||||||
|
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureFlagStatus;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
||||||
|
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||||
|
import SquarePayment from './SquarePayment';
|
||||||
|
|
||||||
interface MembershipSetupProps {
|
interface MembershipSetupProps {
|
||||||
onMembershipCreated: () => void;
|
onMembershipCreated: () => void;
|
||||||
@@ -11,7 +13,11 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
const [selectedTier, setSelectedTier] = useState<MembershipTier | null>(null);
|
const [selectedTier, setSelectedTier] = useState<MembershipTier | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [step, setStep] = useState<'select' | 'payment' | 'confirm'>('select');
|
const [step, setStep] = useState<'select' | 'payment' | 'confirm'>('select');
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<'square' | 'cash' | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [createdMembershipId, setCreatedMembershipId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { isEnabled } = useFeatureFlags();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTiers();
|
loadTiers();
|
||||||
@@ -32,18 +38,56 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
setStep('payment');
|
setStep('payment');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePayment = async () => {
|
const handleCashPayment = async () => {
|
||||||
if (!selectedTier) return;
|
if (!selectedTier || !createdMembershipId) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculate dates (start today, end one year from now)
|
// Create cash/dummy payment
|
||||||
|
const paymentData: PaymentCreateData = {
|
||||||
|
amount: selectedTier.annual_fee,
|
||||||
|
payment_method: 'cash',
|
||||||
|
membership_id: createdMembershipId,
|
||||||
|
notes: `Cash payment for ${selectedTier.name} membership`
|
||||||
|
};
|
||||||
|
|
||||||
|
await paymentService.createPayment(paymentData);
|
||||||
|
setStep('confirm');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to record payment');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSquarePaymentSuccess = (paymentResult: any) => {
|
||||||
|
console.log('Square payment successful:', paymentResult);
|
||||||
|
// Payment was successful, membership was created and activated by the backend
|
||||||
|
setStep('confirm');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSquarePaymentError = (error: string) => {
|
||||||
|
setError(error);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaymentMethodSelect = async (method: 'square' | 'cash') => {
|
||||||
|
setPaymentMethod(method);
|
||||||
|
|
||||||
|
if (!selectedTier) return;
|
||||||
|
|
||||||
|
// For cash payments, create membership in PENDING state
|
||||||
|
// For Square payments, we'll create membership only after successful payment
|
||||||
|
if (method === 'cash') {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
const startDate = new Date().toISOString().split('T')[0];
|
const startDate = new Date().toISOString().split('T')[0];
|
||||||
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
|
||||||
// Create membership
|
|
||||||
const membershipData: MembershipCreateData = {
|
const membershipData: MembershipCreateData = {
|
||||||
tier_id: selectedTier.id,
|
tier_id: selectedTier.id,
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
@@ -52,23 +96,14 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const membership = await membershipService.createMembership(membershipData);
|
const membership = await membershipService.createMembership(membershipData);
|
||||||
|
setCreatedMembershipId(membership.id);
|
||||||
// Create fake payment
|
setLoading(false);
|
||||||
const paymentData: PaymentCreateData = {
|
|
||||||
amount: selectedTier.annual_fee,
|
|
||||||
payment_method: 'dummy',
|
|
||||||
membership_id: membership.id,
|
|
||||||
notes: `Fake payment for ${selectedTier.name} membership`
|
|
||||||
};
|
|
||||||
|
|
||||||
await paymentService.createPayment(paymentData);
|
|
||||||
|
|
||||||
setStep('confirm');
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Failed to create membership');
|
setError(err.response?.data?.detail || 'Failed to create membership');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// For Square, just set the payment method - membership created after successful payment
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
@@ -144,23 +179,57 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ backgroundColor: '#fff3cd', border: '1px solid #ffeaa7', borderRadius: '4px', padding: '16px', marginBottom: '20px' }}>
|
{!paymentMethod && (
|
||||||
<strong>Demo Payment</strong>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
<h4 style={{ marginBottom: '16px' }}>Choose Payment Method</h4>
|
||||||
This is a Cash payment flow for demo purposes. Square / Paypal etc will come soon
|
|
||||||
</p>
|
<div style={{ display: 'grid', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => handlePaymentMethodSelect('square')}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>Credit/Debit Card</strong>
|
||||||
|
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||||
|
Pay securely with Square
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span>→</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isEnabled('CASH_PAYMENT_ENABLED') && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handlePaymentMethodSelect('cash')}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>Cash Payment</strong>
|
||||||
|
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||||
|
Pay in person or by check
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span>→</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handlePayment}
|
|
||||||
disabled={loading}
|
|
||||||
style={{ marginRight: '10px' }}
|
|
||||||
>
|
|
||||||
{loading ? 'Processing...' : 'Complete Cash Payment'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -171,22 +240,100 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paymentMethod === 'square' && selectedTier && (
|
||||||
|
<div>
|
||||||
|
<SquarePayment
|
||||||
|
amount={selectedTier.annual_fee}
|
||||||
|
tierId={selectedTier.id}
|
||||||
|
onPaymentSuccess={handleSquarePaymentSuccess}
|
||||||
|
onPaymentError={handleSquarePaymentError}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setPaymentMethod(null);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Choose Different Payment Method
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paymentMethod === 'cash' && createdMembershipId && (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#fff3cd',
|
||||||
|
border: '1px solid #ffeaa7',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<strong>Cash Payment Selected</strong>
|
||||||
|
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||||
|
Your membership will be marked as pending until an administrator confirms payment receipt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleCashPayment}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ marginRight: '10px' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setPaymentMethod(null);
|
||||||
|
setCreatedMembershipId(null);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Choose Different Payment Method
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 'confirm') {
|
if (step === 'confirm') {
|
||||||
|
const isCashPayment = paymentMethod === 'cash';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ marginBottom: '16px' }}>Membership Created Successfully!</h3>
|
<h3 style={{ marginBottom: '16px' }}>
|
||||||
|
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
{selectedTier && (
|
{selectedTier && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<h4>Your Membership Details:</h4>
|
<h4>Your Membership Details:</h4>
|
||||||
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
||||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||||
<p><strong>Status:</strong> <span className="status-badge status-pending">Pending</span></p>
|
<p><strong>Status:</strong>
|
||||||
|
<span className={`status-badge ${isCashPayment ? 'status-pending' : 'status-active'}`}>
|
||||||
|
{isCashPayment ? 'Pending' : 'Active'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
|
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
|
||||||
Your membership application has been submitted. An administrator will review and activate your membership shortly.
|
{isCashPayment
|
||||||
|
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
|
||||||
|
: 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authService } from '../services/membershipService';
|
import { authService, User } from '../services/membershipService';
|
||||||
|
|
||||||
interface ProfileMenuProps {
|
interface ProfileMenuProps {
|
||||||
userName: string;
|
userName: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
|
user?: User | null;
|
||||||
|
onEditProfile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -36,6 +38,14 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const dropdownStyle: React.CSSProperties = {
|
const dropdownStyle: React.CSSProperties = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '100%',
|
top: '100%',
|
||||||
@@ -44,7 +54,8 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
minWidth: '160px',
|
minWidth: '280px',
|
||||||
|
maxWidth: '320px',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,10 +93,52 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={dropdownStyle}>
|
<div style={dropdownStyle}>
|
||||||
|
{/* Profile Details Section */}
|
||||||
|
{user && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
borderRadius: '4px 4px 0 0'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 'bold', color: '#333' }}>Profile Details</h4>
|
||||||
|
{onEditProfile && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onEditProfile();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: '#0066cc',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#555', lineHeight: '1.6' }}>
|
||||||
|
<p style={{ margin: '4px 0' }}><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
||||||
|
<p style={{ margin: '4px 0' }}><strong>Email:</strong> {user.email}</p>
|
||||||
|
{user.phone && <p style={{ margin: '4px 0' }}><strong>Phone:</strong> {user.phone}</p>}
|
||||||
|
{user.address && <p style={{ margin: '4px 0' }}><strong>Address:</strong> {user.address}</p>}
|
||||||
|
<p style={{ margin: '4px 0' }}><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Menu Items */}
|
||||||
{userRole === 'super_admin' && (
|
{userRole === 'super_admin' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
style={{ ...menuItemStyle, borderRadius: user ? '0' : '4px 4px 0 0' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/membership-tiers');
|
navigate('/membership-tiers');
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -116,15 +169,15 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
...menuItemStyle,
|
...menuItemStyle,
|
||||||
borderRadius: userRole === 'super_admin' ? '0 0 4px 4px' : '4px 4px 0 0',
|
borderRadius: '0',
|
||||||
borderTop: userRole === 'super_admin' ? '1px solid #eee' : 'none'
|
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
|
||||||
}}
|
}}
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
>
|
>
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px' }}
|
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px', borderTop: '1px solid #eee' }}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
Log Out
|
Log Out
|
||||||
@@ -232,19 +285,33 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="modal-buttons">
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="modal-btn-cancel"
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="modal-btn-primary"
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loading ? 'Changing...' : 'Change Password'}
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
386
frontend/src/components/SquarePayment.tsx
Normal file
386
frontend/src/components/SquarePayment.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface SquarePaymentProps {
|
||||||
|
amount: number;
|
||||||
|
onPaymentSuccess: (paymentResult: any) => void;
|
||||||
|
onPaymentError: (error: string) => void;
|
||||||
|
tierId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SquarePayment: React.FC<SquarePaymentProps> = ({
|
||||||
|
amount,
|
||||||
|
onPaymentSuccess,
|
||||||
|
onPaymentError,
|
||||||
|
tierId
|
||||||
|
}) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [card, setCard] = useState<any>(null);
|
||||||
|
const [payments, setPayments] = useState<any>(null);
|
||||||
|
const [squareConfig, setSquareConfig] = useState<any>(null);
|
||||||
|
|
||||||
|
// Billing details state
|
||||||
|
const [cardholderName, setCardholderName] = useState('');
|
||||||
|
const [addressLine1, setAddressLine1] = useState('');
|
||||||
|
const [addressLine2, setAddressLine2] = useState('');
|
||||||
|
const [city, setCity] = useState('');
|
||||||
|
const [postalCode, setPostalCode] = useState('');
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSquareConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (squareConfig && !payments) {
|
||||||
|
initializeSquare();
|
||||||
|
}
|
||||||
|
}, [squareConfig]);
|
||||||
|
|
||||||
|
const loadSquareSDK = (environment: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Check if Square SDK is already loaded
|
||||||
|
if (window.Square) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
|
||||||
|
// Load the correct SDK based on environment
|
||||||
|
if (environment?.toLowerCase() === 'sandbox') {
|
||||||
|
script.src = 'https://sandbox.web.squarecdn.com/v1/square.js';
|
||||||
|
} else {
|
||||||
|
script.src = 'https://web.squarecdn.com/v1/square.js';
|
||||||
|
}
|
||||||
|
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error('Failed to load Square SDK'));
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSquareConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/payments/config/square', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const config = await response.json();
|
||||||
|
console.log('Square config received:', config);
|
||||||
|
|
||||||
|
// Load the appropriate Square SDK based on environment
|
||||||
|
await loadSquareSDK(config.environment);
|
||||||
|
console.log('Square SDK loaded for environment:', config.environment);
|
||||||
|
|
||||||
|
setSquareConfig(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Square config:', error);
|
||||||
|
onPaymentError('Failed to load payment configuration');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeSquare = async () => {
|
||||||
|
if (!window.Square) {
|
||||||
|
console.error('Square.js failed to load');
|
||||||
|
onPaymentError('Payment system failed to load. Please refresh the page.');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine environment - default to production if not explicitly set to sandbox
|
||||||
|
const environment = squareConfig.environment?.toLowerCase() === 'sandbox' ? 'sandbox' : 'production';
|
||||||
|
console.log('Initializing Square with environment:', environment);
|
||||||
|
console.log('Application ID:', squareConfig.application_id);
|
||||||
|
console.log('Location ID:', squareConfig.location_id);
|
||||||
|
|
||||||
|
const paymentsInstance = window.Square.payments(
|
||||||
|
squareConfig.application_id,
|
||||||
|
squareConfig.location_id,
|
||||||
|
{
|
||||||
|
environment: environment
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setPayments(paymentsInstance);
|
||||||
|
|
||||||
|
// Initialize card without postal code (we collect it separately in billing form)
|
||||||
|
const cardInstance = await paymentsInstance.card({
|
||||||
|
style: {
|
||||||
|
'.input-container': {
|
||||||
|
borderColor: '#E0E0E0',
|
||||||
|
borderRadius: '4px'
|
||||||
|
},
|
||||||
|
'.input-container.is-focus': {
|
||||||
|
borderColor: '#4CAF50'
|
||||||
|
},
|
||||||
|
'.message-text': {
|
||||||
|
color: '#999'
|
||||||
|
},
|
||||||
|
'.message-icon': {
|
||||||
|
color: '#999'
|
||||||
|
},
|
||||||
|
'input': {
|
||||||
|
fontSize: '14px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await cardInstance.attach('#card-container');
|
||||||
|
setCard(cardInstance);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Square:', error);
|
||||||
|
onPaymentError('Failed to initialize payment form');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayment = async () => {
|
||||||
|
if (!card || !payments) {
|
||||||
|
onPaymentError('Payment form not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate billing details
|
||||||
|
if (!cardholderName.trim()) {
|
||||||
|
onPaymentError('Please enter cardholder name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!addressLine1.trim()) {
|
||||||
|
onPaymentError('Please enter address line 1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!city.trim()) {
|
||||||
|
onPaymentError('Please enter city');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!postalCode.trim()) {
|
||||||
|
onPaymentError('Please enter postal code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tokenize the payment method with billing details
|
||||||
|
const result = await card.tokenize();
|
||||||
|
|
||||||
|
if (result.status === 'OK') {
|
||||||
|
// Send the token to your backend with billing details
|
||||||
|
const response = await fetch('/api/v1/payments/square/process', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
source_id: result.token,
|
||||||
|
amount: amount,
|
||||||
|
tier_id: tierId,
|
||||||
|
note: `Membership payment - £${amount.toFixed(2)}`,
|
||||||
|
billing_details: {
|
||||||
|
cardholder_name: cardholderName,
|
||||||
|
address_line_1: addressLine1,
|
||||||
|
address_line_2: addressLine2 || undefined,
|
||||||
|
city: city,
|
||||||
|
postal_code: postalCode,
|
||||||
|
country: 'GB'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentResult = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && paymentResult.success) {
|
||||||
|
onPaymentSuccess(paymentResult);
|
||||||
|
} else {
|
||||||
|
// Handle error response gracefully
|
||||||
|
let errorMessage = 'Payment failed. Please try again.';
|
||||||
|
|
||||||
|
if (paymentResult.errors && Array.isArray(paymentResult.errors) && paymentResult.errors.length > 0) {
|
||||||
|
// Show the first error message (they're already user-friendly from backend)
|
||||||
|
errorMessage = paymentResult.errors[0];
|
||||||
|
} else if (paymentResult.detail) {
|
||||||
|
errorMessage = paymentResult.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaymentError(errorMessage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errors = result.errors?.map((e: any) => e.message).join(', ') || 'Card tokenization failed';
|
||||||
|
onPaymentError(errors);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Payment error:', error);
|
||||||
|
onPaymentError(error.message || 'Payment processing failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '500px', margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h4 style={{ marginBottom: '10px' }}>Card Payment</h4>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Amount: <strong>£{amount.toFixed(2)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cardholder Name */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Cardholder Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cardholderName}
|
||||||
|
onChange={(e) => setCardholderName(e.target.value)}
|
||||||
|
placeholder="Name as it appears on card"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Line 1 */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Address Line 1 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addressLine1}
|
||||||
|
onChange={(e) => setAddressLine1(e.target.value)}
|
||||||
|
placeholder="Street address"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Line 2 */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Address Line 2
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addressLine2}
|
||||||
|
onChange={(e) => setAddressLine2(e.target.value)}
|
||||||
|
placeholder="Apartment, suite, etc. (optional)"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City and Postal Code */}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ flex: '1' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Town/City *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
placeholder="City"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '1' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Postcode *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={postalCode}
|
||||||
|
onChange={(e) => setPostalCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="SW1A 1AA"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Details */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Card Details *
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
id="card-container"
|
||||||
|
style={{
|
||||||
|
minHeight: '120px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handlePayment}
|
||||||
|
disabled={isLoading || !card || isProcessing}
|
||||||
|
style={{ width: '100%', padding: '12px' }}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Processing...' : `Pay £${amount.toFixed(2)}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666', textAlign: 'center' }}>
|
||||||
|
<p>Secure payment powered by Square</p>
|
||||||
|
{squareConfig?.environment === 'sandbox' && (
|
||||||
|
<p style={{ color: '#ff9800', marginTop: '8px' }}>
|
||||||
|
<strong>Test Mode:</strong> Use card 4111 1111 1111 1111 with any future expiry
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extend Window interface for TypeScript
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Square: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SquarePayment;
|
||||||
94
frontend/src/contexts/FeatureFlagContext.tsx
Normal file
94
frontend/src/contexts/FeatureFlagContext.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { featureFlagService, FeatureFlags } from '../services/featureFlagService';
|
||||||
|
|
||||||
|
interface FeatureFlagContextType {
|
||||||
|
flags: FeatureFlags | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isEnabled: (flagName: string) => boolean;
|
||||||
|
getFlagValue: (flagName: string, defaultValue?: any) => any;
|
||||||
|
reloadFlags: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureFlagContext = createContext<FeatureFlagContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useFeatureFlags = (): FeatureFlagContextType => {
|
||||||
|
const context = useContext(FeatureFlagContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeatureFlagProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeatureFlagProvider: React.FC<FeatureFlagProviderProps> = ({ children }) => {
|
||||||
|
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadFlags = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const flagsData = await featureFlagService.getAllFlags();
|
||||||
|
setFlags(flagsData);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load feature flags:', err);
|
||||||
|
setError('Failed to load feature flags');
|
||||||
|
// Set default flags on error
|
||||||
|
setFlags({
|
||||||
|
flags: {
|
||||||
|
CASH_PAYMENT_ENABLED: true,
|
||||||
|
EMAIL_NOTIFICATIONS_ENABLED: true,
|
||||||
|
EVENT_MANAGEMENT_ENABLED: true,
|
||||||
|
AUTO_RENEWAL_ENABLED: false,
|
||||||
|
MEMBERSHIP_TRANSFERS_ENABLED: false,
|
||||||
|
BULK_OPERATIONS_ENABLED: false,
|
||||||
|
ADVANCED_REPORTING_ENABLED: false,
|
||||||
|
API_RATE_LIMITING_ENABLED: true,
|
||||||
|
},
|
||||||
|
enabled_flags: ['CASH_PAYMENT_ENABLED', 'EMAIL_NOTIFICATIONS_ENABLED', 'EVENT_MANAGEMENT_ENABLED', 'API_RATE_LIMITING_ENABLED']
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadFlags = async () => {
|
||||||
|
await loadFlags();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEnabled = (flagName: string): boolean => {
|
||||||
|
if (!flags) return false;
|
||||||
|
const upperFlagName = flagName.toUpperCase();
|
||||||
|
return Boolean(flags.flags[upperFlagName]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFlagValue = (flagName: string, defaultValue: any = null): any => {
|
||||||
|
if (!flags) return defaultValue;
|
||||||
|
const upperFlagName = flagName.toUpperCase();
|
||||||
|
return flags.flags[upperFlagName] ?? defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFlags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue: FeatureFlagContextType = {
|
||||||
|
flags,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isEnabled,
|
||||||
|
getFlagValue,
|
||||||
|
reloadFlags,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureFlagContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</FeatureFlagContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authService, userService, membershipService, paymentService, User, Membership, Payment } from '../services/membershipService';
|
import { authService, userService, membershipService, paymentService, eventService, User, Membership, Payment, Event, EventRSVP } from '../services/membershipService';
|
||||||
import MembershipSetup from '../components/MembershipSetup';
|
import MembershipSetup from '../components/MembershipSetup';
|
||||||
import ProfileMenu from '../components/ProfileMenu';
|
import ProfileMenu from '../components/ProfileMenu';
|
||||||
import ProfileEdit from '../components/ProfileEdit';
|
import ProfileEdit from '../components/ProfileEdit';
|
||||||
|
import FeatureFlagStatus from '../components/FeatureFlagStatus';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,6 +22,25 @@ const Dashboard: React.FC = () => {
|
|||||||
const [showUserDetails, setShowUserDetails] = useState(false);
|
const [showUserDetails, setShowUserDetails] = useState(false);
|
||||||
const [isEditingUser, setIsEditingUser] = useState(false);
|
const [isEditingUser, setIsEditingUser] = useState(false);
|
||||||
const [editFormData, setEditFormData] = useState<Partial<User>>({});
|
const [editFormData, setEditFormData] = useState<Partial<User>>({});
|
||||||
|
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
|
||||||
|
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||||
|
const [eventRSVPCounts, setEventRSVPCounts] = useState<{[eventId: number]: {attending: number, maybe: number, not_attending: number}}>({});
|
||||||
|
const [rsvpLoading, setRsvpLoading] = useState<{[eventId: number]: boolean}>({});
|
||||||
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||||
|
const [eventFormData, setEventFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
event_date: '',
|
||||||
|
event_time: '',
|
||||||
|
location: '',
|
||||||
|
max_attendees: ''
|
||||||
|
});
|
||||||
|
const [showRSVPModal, setShowRSVPModal] = useState(false);
|
||||||
|
const [selectedEventForRSVP, setSelectedEventForRSVP] = useState<Event | null>(null);
|
||||||
|
const [eventRSVPList, setEventRSVPList] = useState<EventRSVP[]>([]);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authService.isAuthenticated()) {
|
if (!authService.isAuthenticated()) {
|
||||||
@@ -31,6 +51,16 @@ const Dashboard: React.FC = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const mergeRSVPStatus = (events: Event[], rsvps: EventRSVP[]): Event[] => {
|
||||||
|
return events.map(event => {
|
||||||
|
const rsvp = rsvps.find(r => r.event_id === event.id);
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
rsvp_status: rsvp ? rsvp.status : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [userData, membershipData, paymentData] = await Promise.all([
|
const [userData, membershipData, paymentData] = await Promise.all([
|
||||||
@@ -43,16 +73,31 @@ const Dashboard: React.FC = () => {
|
|||||||
setMemberships(membershipData);
|
setMemberships(membershipData);
|
||||||
setPayments(paymentData);
|
setPayments(paymentData);
|
||||||
|
|
||||||
|
// Load upcoming events and user's RSVPs
|
||||||
|
const [eventsData, rsvpsData] = await Promise.all([
|
||||||
|
eventService.getUpcomingEvents(),
|
||||||
|
eventService.getMyRSVPs()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge RSVP status with events
|
||||||
|
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
|
||||||
|
setUpcomingEvents(eventsWithRSVP);
|
||||||
|
|
||||||
// Load admin data if user is admin
|
// Load admin data if user is admin
|
||||||
if (userData.role === 'admin' || userData.role === 'super_admin') {
|
if (userData.role === 'admin' || userData.role === 'super_admin') {
|
||||||
const [allPaymentsData, allMembershipsData, allUsersData] = await Promise.all([
|
const [allPaymentsData, allMembershipsData, allUsersData, allEventsData] = await Promise.all([
|
||||||
paymentService.getAllPayments(),
|
paymentService.getAllPayments(),
|
||||||
membershipService.getAllMemberships(),
|
membershipService.getAllMemberships(),
|
||||||
userService.getAllUsers()
|
userService.getAllUsers(),
|
||||||
|
eventService.getAllEvents()
|
||||||
]);
|
]);
|
||||||
setAllPayments(allPaymentsData);
|
setAllPayments(allPaymentsData);
|
||||||
setAllMemberships(allMembershipsData);
|
setAllMemberships(allMembershipsData);
|
||||||
setAllUsers(allUsersData);
|
setAllUsers(allUsersData);
|
||||||
|
setAllEvents(allEventsData);
|
||||||
|
|
||||||
|
// Load RSVP counts for all events
|
||||||
|
await loadEventRSVPCounts(allEventsData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load data:', error);
|
console.error('Failed to load data:', error);
|
||||||
@@ -61,6 +106,26 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadEventRSVPCounts = async (events: Event[]) => {
|
||||||
|
const counts: {[eventId: number]: {attending: number, maybe: number, not_attending: number}} = {};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
try {
|
||||||
|
const rsvps = await eventService.getEventRSVPs(event.id);
|
||||||
|
counts[event.id] = {
|
||||||
|
attending: rsvps.filter(r => r.status === 'attending').length,
|
||||||
|
maybe: rsvps.filter(r => r.status === 'maybe').length,
|
||||||
|
not_attending: rsvps.filter(r => r.status === 'not_attending').length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load RSVPs for event ${event.id}:`, error);
|
||||||
|
counts[event.id] = { attending: 0, maybe: 0, not_attending: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEventRSVPCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
const handleMembershipSetup = () => {
|
const handleMembershipSetup = () => {
|
||||||
setShowMembershipSetup(true);
|
setShowMembershipSetup(true);
|
||||||
};
|
};
|
||||||
@@ -121,6 +186,26 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
if (!userToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userService.deleteUser(userToDelete.id);
|
||||||
|
await loadData(); // Reload data to reflect changes
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setUserToDelete(null);
|
||||||
|
alert('User deleted successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to delete user:', error);
|
||||||
|
alert(`Failed to delete user: ${error.response?.data?.detail || error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteUser = (u: User) => {
|
||||||
|
setUserToDelete(u);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusClass = (status: string) => {
|
const getStatusClass = (status: string) => {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case 'active':
|
case 'active':
|
||||||
@@ -179,6 +264,13 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFormChange = (field: string, value: string) => {
|
||||||
|
setEditFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveUser = async () => {
|
const handleSaveUser = async () => {
|
||||||
if (!selectedUser) return;
|
if (!selectedUser) return;
|
||||||
|
|
||||||
@@ -198,13 +290,169 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormChange = (field: keyof User, value: string) => {
|
const handleRSVP = async (eventId: number, status: 'attending' | 'not_attending' | 'maybe') => {
|
||||||
setEditFormData(prev => ({
|
// Set loading state for this event
|
||||||
|
setRsvpLoading(prev => ({ ...prev, [eventId]: true }));
|
||||||
|
|
||||||
|
// Optimistically update the UI
|
||||||
|
setUpcomingEvents(prevEvents =>
|
||||||
|
prevEvents.map(event =>
|
||||||
|
event.id === eventId
|
||||||
|
? { ...event, rsvp_status: status }
|
||||||
|
: event
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await eventService.createOrUpdateRSVP(eventId, { status });
|
||||||
|
// Reload RSVPs and merge with events to get the latest data
|
||||||
|
const [eventsData, rsvpsData] = await Promise.all([
|
||||||
|
eventService.getUpcomingEvents(),
|
||||||
|
eventService.getMyRSVPs()
|
||||||
|
]);
|
||||||
|
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
|
||||||
|
setUpcomingEvents(eventsWithRSVP);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update RSVP:', error);
|
||||||
|
alert('Failed to update RSVP. Please try again.');
|
||||||
|
// Revert optimistic update on error
|
||||||
|
const [eventsData, rsvpsData] = await Promise.all([
|
||||||
|
eventService.getUpcomingEvents(),
|
||||||
|
eventService.getMyRSVPs()
|
||||||
|
]);
|
||||||
|
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
|
||||||
|
setUpcomingEvents(eventsWithRSVP);
|
||||||
|
} finally {
|
||||||
|
// Clear loading state
|
||||||
|
setRsvpLoading(prev => ({ ...prev, [eventId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublishEvent = async (eventId: number) => {
|
||||||
|
try {
|
||||||
|
await eventService.updateEvent(eventId, { status: 'published' });
|
||||||
|
// Reload events to reflect the change
|
||||||
|
const eventsData = await eventService.getAllEvents();
|
||||||
|
setAllEvents(eventsData);
|
||||||
|
// Reload RSVP counts
|
||||||
|
await loadEventRSVPCounts(eventsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to publish event:', error);
|
||||||
|
alert('Failed to publish event. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEvent = async (eventId: number) => {
|
||||||
|
if (!confirm('Are you sure you want to cancel this event?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await eventService.updateEvent(eventId, { status: 'cancelled' });
|
||||||
|
// Reload events to reflect the change
|
||||||
|
const eventsData = await eventService.getAllEvents();
|
||||||
|
setAllEvents(eventsData);
|
||||||
|
// Reload RSVP counts
|
||||||
|
await loadEventRSVPCounts(eventsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to cancel event:', error);
|
||||||
|
alert('Failed to cancel event. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateEvent = () => {
|
||||||
|
setEditingEvent(null);
|
||||||
|
setEventFormData({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
event_date: '',
|
||||||
|
event_time: '',
|
||||||
|
location: '',
|
||||||
|
max_attendees: ''
|
||||||
|
});
|
||||||
|
setShowEventModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditEvent = (event: Event) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
|
||||||
|
// Convert event_date to YYYY-MM-DD format for date input
|
||||||
|
const dateObj = new Date(event.event_date);
|
||||||
|
const formattedDate = dateObj.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
setEventFormData({
|
||||||
|
title: event.title,
|
||||||
|
description: event.description || '',
|
||||||
|
event_date: formattedDate,
|
||||||
|
event_time: event.event_time || '',
|
||||||
|
location: event.location || '',
|
||||||
|
max_attendees: event.max_attendees?.toString() || ''
|
||||||
|
});
|
||||||
|
setShowEventModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEventFormChange = (field: string, value: string) => {
|
||||||
|
setEventFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[field]: value
|
[field]: value
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveEvent = async () => {
|
||||||
|
try {
|
||||||
|
const eventData = {
|
||||||
|
title: eventFormData.title,
|
||||||
|
description: eventFormData.description || undefined,
|
||||||
|
event_date: eventFormData.event_date,
|
||||||
|
event_time: eventFormData.event_time || undefined,
|
||||||
|
location: eventFormData.location || undefined,
|
||||||
|
max_attendees: eventFormData.max_attendees ? parseInt(eventFormData.max_attendees) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingEvent) {
|
||||||
|
// Update existing event
|
||||||
|
await eventService.updateEvent(editingEvent.id, eventData);
|
||||||
|
} else {
|
||||||
|
// Create new event
|
||||||
|
await eventService.createEvent(eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload events
|
||||||
|
const eventsData = await eventService.getAllEvents();
|
||||||
|
setAllEvents(eventsData);
|
||||||
|
await loadEventRSVPCounts(eventsData);
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
setShowEventModal(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save event:', error);
|
||||||
|
alert('Failed to save event. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEventModal = () => {
|
||||||
|
setShowEventModal(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewRSVPs = async (event: Event) => {
|
||||||
|
setSelectedEventForRSVP(event);
|
||||||
|
try {
|
||||||
|
const rsvps = await eventService.getEventRSVPs(event.id);
|
||||||
|
setEventRSVPList(rsvps);
|
||||||
|
setShowRSVPModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load RSVPs:', error);
|
||||||
|
alert('Failed to load RSVPs. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseRSVPModal = () => {
|
||||||
|
setShowRSVPModal(false);
|
||||||
|
setSelectedEventForRSVP(null);
|
||||||
|
setEventRSVPList([]);
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-GB', {
|
return new Date(dateString).toLocaleDateString('en-GB', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -240,41 +488,28 @@ const Dashboard: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<h1>SASA Membership Portal</h1>
|
<h1>SASA Membership Portal</h1>
|
||||||
<ProfileMenu userName={`${user?.first_name} ${user?.last_name}`} userRole={user?.role || ''} />
|
<ProfileMenu
|
||||||
|
userName={`${user?.first_name} ${user?.last_name}`}
|
||||||
|
userRole={user?.role || ''}
|
||||||
|
user={user}
|
||||||
|
onEditProfile={handleProfileEdit}
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h2 style={{ marginTop: '20px', marginBottom: '20px' }}>Welcome, {user?.first_name}!</h2>
|
<h2 style={{ marginTop: '20px', marginBottom: '20px' }}>Welcome, {user?.first_name}!</h2>
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
{/* Profile Card */}
|
|
||||||
<div className="card">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|
||||||
<h3>Your Profile</h3>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={handleProfileEdit}
|
|
||||||
style={{ fontSize: '14px', padding: '6px 12px' }}
|
|
||||||
>
|
|
||||||
Edit Profile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p><strong>Name:</strong> {user?.first_name} {user?.last_name}</p>
|
|
||||||
<p><strong>Email:</strong> {user?.email}</p>
|
|
||||||
{user?.phone && <p><strong>Phone:</strong> {user.phone}</p>}
|
|
||||||
{user?.address && <p><strong>Address:</strong> {user.address}</p>}
|
|
||||||
<p><strong>Registered since:</strong> {user && formatDate(user.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Membership Card */}
|
{/* Membership Card */}
|
||||||
{activeMembership ? (
|
{activeMembership ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ marginBottom: '16px' }}>Your Membership</h3>
|
<h3 style={{ marginBottom: '16px' }}>Your Membership</h3>
|
||||||
<h4 style={{ color: '#0066cc', marginBottom: '8px' }}>{activeMembership.tier.name}</h4>
|
<h4 style={{ color: '#0066cc', marginBottom: '8px' }}>{activeMembership.tier.name}</h4>
|
||||||
|
<p><strong>Membership Number:</strong> {activeMembership.id}</p>
|
||||||
<p><strong>Status:</strong> <span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span></p>
|
<p><strong>Status:</strong> <span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span></p>
|
||||||
<p><strong>Annual Fee:</strong> £{activeMembership.tier.annual_fee.toFixed(2)}</p>
|
<p><strong>Annual Fee:</strong> £{activeMembership.tier.annual_fee.toFixed(2)}</p>
|
||||||
<p><strong>Member since:</strong> {formatDate(activeMembership.start_date)}</p>
|
<p><strong>Valid From:</strong> {formatDate(activeMembership.start_date)}</p>
|
||||||
<p><strong>Renewal Date:</strong> {formatDate(activeMembership.end_date)}</p>
|
<p><strong>Valid Until:</strong> {formatDate(activeMembership.end_date)}</p>
|
||||||
<p><strong>Auto Renew:</strong> {activeMembership.auto_renew ? 'Yes' : 'No'}</p>
|
<p><strong>Auto Renew:</strong> {activeMembership.auto_renew ? 'Yes' : 'No'}</p>
|
||||||
<div style={{ marginTop: '12px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
<div style={{ marginTop: '12px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
<strong>Benefits:</strong>
|
<strong>Benefits:</strong>
|
||||||
@@ -295,12 +530,74 @@ const Dashboard: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginBottom: '16px' }}>Upcoming Events</h3>
|
||||||
|
{upcomingEvents.length > 0 ? (
|
||||||
|
<div className="events-container">
|
||||||
|
{upcomingEvents.map(event => (
|
||||||
|
<div key={event.id} className="event-card">
|
||||||
|
<div className="event-header">
|
||||||
|
<div className="event-info">
|
||||||
|
<h4 className="event-title">{event.title}</h4>
|
||||||
|
<p className="event-datetime">
|
||||||
|
{formatDate(event.event_date)} at {event.event_time}
|
||||||
|
</p>
|
||||||
|
{event.location && (
|
||||||
|
<p className="event-location">
|
||||||
|
📍 {event.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="event-rsvp-buttons">
|
||||||
|
<button
|
||||||
|
className={`rsvp-btn rsvp-btn-attending ${event.rsvp_status === 'attending' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRSVP(event.id, 'attending')}
|
||||||
|
disabled={rsvpLoading[event.id]}
|
||||||
|
>
|
||||||
|
{rsvpLoading[event.id] ? '...' : 'Attending'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rsvp-btn rsvp-btn-maybe ${event.rsvp_status === 'maybe' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRSVP(event.id, 'maybe')}
|
||||||
|
disabled={rsvpLoading[event.id]}
|
||||||
|
>
|
||||||
|
{rsvpLoading[event.id] ? '...' : 'Maybe'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rsvp-btn rsvp-btn-not-attending ${event.rsvp_status === 'not_attending' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRSVP(event.id, 'not_attending')}
|
||||||
|
disabled={rsvpLoading[event.id]}
|
||||||
|
>
|
||||||
|
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{event.description && (
|
||||||
|
<p className="event-description">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{event.rsvp_status && (
|
||||||
|
<div className={`event-rsvp-status ${event.rsvp_status}`}>
|
||||||
|
<strong>Your RSVP:</strong> <span style={{ textTransform: 'capitalize' }}>{event.rsvp_status.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#666' }}>No upcoming events at this time.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment History */}
|
{/* Payment History */}
|
||||||
<div className="card" style={{ marginTop: '20px' }}>
|
<div className="card" style={{ marginTop: '20px' }}>
|
||||||
<h3 style={{ marginBottom: '16px' }}>Payment History</h3>
|
<h3 style={{ marginBottom: '16px' }}>Payment History</h3>
|
||||||
{payments.length > 0 ? (
|
{payments.length > 0 ? (
|
||||||
|
<div className="table-container">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
@@ -325,6 +622,7 @@ const Dashboard: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: '#666' }}>No payment history available.</p>
|
<p style={{ color: '#666' }}>No payment history available.</p>
|
||||||
)}
|
)}
|
||||||
@@ -339,6 +637,7 @@ const Dashboard: React.FC = () => {
|
|||||||
{allPayments.filter(p => p.status === 'pending').length > 0 && (
|
{allPayments.filter(p => p.status === 'pending').length > 0 && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<h4 style={{ marginBottom: '12px' }}>Pending Payments</h4>
|
<h4 style={{ marginBottom: '12px' }}>Pending Payments</h4>
|
||||||
|
<div className="table-container">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
@@ -375,12 +674,14 @@ const Dashboard: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pending Memberships */}
|
{/* Pending Memberships */}
|
||||||
{allMemberships.filter(m => m.status === 'pending').length > 0 && (
|
{allMemberships.filter(m => m.status === 'pending').length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 style={{ marginBottom: '12px' }}>Pending Memberships</h4>
|
<h4 style={{ marginBottom: '12px' }}>Pending Memberships</h4>
|
||||||
|
<div className="table-container">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
@@ -406,6 +707,7 @@ const Dashboard: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allPayments.filter(p => p.status === 'pending').length === 0 &&
|
{allPayments.filter(p => p.status === 'pending').length === 0 &&
|
||||||
@@ -415,6 +717,11 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Feature Flag Management - Super Admin Only */}
|
||||||
|
{user?.role === 'super_admin' && (
|
||||||
|
<FeatureFlagStatus />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Management Section */}
|
{/* User Management Section */}
|
||||||
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||||
<div className="card" style={{ marginTop: '20px' }}>
|
<div className="card" style={{ marginTop: '20px' }}>
|
||||||
@@ -437,11 +744,13 @@ const Dashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Email</th>
|
<th style={{ padding: '12px', textAlign: 'left' }}>Email</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Membership #</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Role</th>
|
<th style={{ padding: '12px', textAlign: 'left' }}>Role</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Joined</th>
|
<th style={{ padding: '12px', textAlign: 'left' }}>Joined</th>
|
||||||
@@ -449,7 +758,9 @@ const Dashboard: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredUsers.map(u => (
|
{filteredUsers.map(u => {
|
||||||
|
const userMembership = allMemberships.find(m => m.user_id === u.id && m.status === 'active');
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={u.id}
|
key={u.id}
|
||||||
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
|
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
|
||||||
@@ -457,6 +768,7 @@ const Dashboard: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<td style={{ padding: '12px' }}>{u.first_name} {u.last_name}</td>
|
<td style={{ padding: '12px' }}>{u.first_name} {u.last_name}</td>
|
||||||
<td style={{ padding: '12px' }}>{u.email}</td>
|
<td style={{ padding: '12px' }}>{u.email}</td>
|
||||||
|
<td style={{ padding: '12px' }}>{userMembership ? userMembership.id : 'N/A'}</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: '12px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
backgroundColor: u.role === 'super_admin' ? '#dc3545' :
|
backgroundColor: u.role === 'super_admin' ? '#dc3545' :
|
||||||
@@ -504,12 +816,26 @@ const Dashboard: React.FC = () => {
|
|||||||
{u.role === 'super_admin' && (
|
{u.role === 'super_admin' && (
|
||||||
<span style={{ fontSize: '12px', color: '#666' }}>Super Admin</span>
|
<span style={{ fontSize: '12px', color: '#666' }}>Super Admin</span>
|
||||||
)}
|
)}
|
||||||
|
{user?.role === 'super_admin' && u.id !== user?.id && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent row click
|
||||||
|
confirmDeleteUser(u);
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px', marginLeft: '4px' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -521,6 +847,123 @@ const Dashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Event Management Section for Admins */}
|
||||||
|
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||||
|
<div className="card" style={{ marginTop: '20px' }}>
|
||||||
|
<h3 style={{ marginBottom: '16px' }}>Event Management</h3>
|
||||||
|
|
||||||
|
{/* Create New Event Button */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleCreateEvent}
|
||||||
|
style={{ fontSize: '14px', padding: '8px 16px' }}
|
||||||
|
>
|
||||||
|
Create New Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events List */}
|
||||||
|
<div className="table-container">
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Event</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Date & Time</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Location</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>RSVPs</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allEvents.map(event => (
|
||||||
|
<tr
|
||||||
|
key={event.id}
|
||||||
|
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
|
||||||
|
onClick={() => handleViewRSVPs(event)}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<strong>{event.title}</strong>
|
||||||
|
{event.description && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{event.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<div>{formatDate(event.event_date)}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>{event.event_time}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>{event.location || 'TBD'}</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span className={`status-badge ${event.status === 'published' ? 'status-active' : event.status === 'cancelled' ? 'status-expired' : 'status-pending'}`}>
|
||||||
|
{event.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{eventRSVPCounts[event.id] ? (
|
||||||
|
<div style={{ fontSize: '12px' }}>
|
||||||
|
<div>Attending: {eventRSVPCounts[event.id].attending}</div>
|
||||||
|
<div>Maybe: {eventRSVPCounts[event.id].maybe}</div>
|
||||||
|
<div>Not: {eventRSVPCounts[event.id].not_attending}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '12px', color: '#666' }}>Loading...</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditEvent(event);
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{event.status === 'draft' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePublishEvent(event.id);
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{event.status === 'published' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCancelEvent(event.id);
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allEvents.length === 0 && (
|
||||||
|
<p style={{ color: '#666', textAlign: 'center', padding: '20px' }}>No events created yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Details Modal */}
|
{/* User Details Modal */}
|
||||||
{showUserDetails && selectedUser && (
|
{showUserDetails && selectedUser && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -765,6 +1208,248 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Event Create/Edit Modal */}
|
||||||
|
{showEventModal && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '600px' }}>
|
||||||
|
<h3>{editingEvent ? 'Edit Event' : 'Create New Event'}</h3>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Event Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eventFormData.title}
|
||||||
|
onChange={(e) => handleEventFormChange('title', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Annual General Meeting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea
|
||||||
|
value={eventFormData.description}
|
||||||
|
onChange={(e) => handleEventFormChange('description', e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
|
placeholder="Event details and agenda..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Event Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={eventFormData.event_date}
|
||||||
|
onChange={(e) => handleEventFormChange('event_date', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Event Time</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={eventFormData.event_time}
|
||||||
|
onChange={(e) => handleEventFormChange('event_time', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Location</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eventFormData.location}
|
||||||
|
onChange={(e) => handleEventFormChange('location', e.target.value)}
|
||||||
|
placeholder="Swansea Airport Conference Room"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Max Attendees (optional)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={eventFormData.max_attendees}
|
||||||
|
onChange={(e) => handleEventFormChange('max_attendees', e.target.value)}
|
||||||
|
min="1"
|
||||||
|
placeholder="Leave blank for unlimited"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseEventModal}
|
||||||
|
className="modal-btn-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveEvent}
|
||||||
|
className="modal-btn-primary"
|
||||||
|
disabled={!eventFormData.title || !eventFormData.event_date}
|
||||||
|
>
|
||||||
|
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RSVP List Modal */}
|
||||||
|
{showRSVPModal && selectedEventForRSVP && (
|
||||||
|
<div className="modal-overlay" onClick={handleCloseRSVPModal}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: '700px', maxHeight: '80vh', overflow: 'auto' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>RSVPs for {selectedEventForRSVP.title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseRSVPModal}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Date:</strong> {formatDate(selectedEventForRSVP.event_date)}</p>
|
||||||
|
{selectedEventForRSVP.event_time && (
|
||||||
|
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Time:</strong> {selectedEventForRSVP.event_time}</p>
|
||||||
|
)}
|
||||||
|
{selectedEventForRSVP.location && (
|
||||||
|
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Location:</strong> {selectedEventForRSVP.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eventRSVPList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '16px', display: 'flex', gap: '16px', fontSize: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<strong>Attending:</strong> {eventRSVPList.filter(r => r.status === 'attending').length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Maybe:</strong> {eventRSVPList.filter(r => r.status === 'maybe').length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Not Attending:</strong> {eventRSVPList.filter(r => r.status === 'not_attending').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Email</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>RSVP</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{eventRSVPList.map(rsvp => {
|
||||||
|
const rsvpUser = allUsers.find(u => u.id === rsvp.user_id);
|
||||||
|
return (
|
||||||
|
<tr key={rsvp.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{rsvpUser ? `${rsvpUser.first_name} ${rsvpUser.last_name}` : `User #${rsvp.user_id}`}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{rsvpUser?.email || 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span className={`status-badge ${
|
||||||
|
rsvp.status === 'attending' ? 'status-active' :
|
||||||
|
rsvp.status === 'maybe' ? 'status-pending' :
|
||||||
|
'status-expired'
|
||||||
|
}`}>
|
||||||
|
{rsvp.status === 'attending' ? 'ATTENDING' :
|
||||||
|
rsvp.status === 'maybe' ? 'MAYBE' :
|
||||||
|
'NOT ATTENDING'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '12px', color: '#666' }}>
|
||||||
|
{rsvp.created_at ? formatDate(rsvp.created_at) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ textAlign: 'center', color: '#666', padding: '20px' }}>No RSVPs yet for this event.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete User Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && userToDelete && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content">
|
||||||
|
<h3 style={{ color: '#dc3545' }}>Delete User</h3>
|
||||||
|
<p>Are you sure you want to delete the user <strong>{userToDelete.first_name} {userToDelete.last_name}</strong> ({userToDelete.email})?</p>
|
||||||
|
<p style={{ color: '#dc3545', fontSize: '14px' }}>
|
||||||
|
⚠️ This action cannot be undone. All associated memberships and payments will also be deleted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setUserToDelete(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteUser}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,14 +27,23 @@ const Login: React.FC = () => {
|
|||||||
await authService.login(formData);
|
await authService.login(formData);
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.');
|
console.error('Login error:', err.response?.data); // Debug log
|
||||||
|
const errorDetail = err.response?.data?.detail;
|
||||||
|
if (typeof errorDetail === 'string') {
|
||||||
|
setError(errorDetail);
|
||||||
|
} else if (errorDetail && typeof errorDetail === 'object') {
|
||||||
|
// Handle validation error objects
|
||||||
|
setError('Login failed. Please check your credentials.');
|
||||||
|
} else {
|
||||||
|
setError('Login failed. Please check your credentials.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container" style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: '40px', padding: '20px' }}>
|
<div className="auth-container" style={{ gap: '40px', padding: '20px' }}>
|
||||||
<div className="welcome-section" style={{
|
<div className="welcome-section" style={{
|
||||||
flex: '1',
|
flex: '1',
|
||||||
maxWidth: '400px',
|
maxWidth: '400px',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||||
|
|
||||||
const MembershipTiers: React.FC = () => {
|
const MembershipTiers: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
@@ -82,30 +84,64 @@ const MembershipTiers: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
padding: '20px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center'
|
||||||
marginBottom: '30px'
|
|
||||||
}}>
|
}}>
|
||||||
<h1 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h1>
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '24px' }}>Membership Tiers Management</h1>
|
||||||
|
<p style={{ margin: '5px 0 0 0', opacity: 0.9 }}>
|
||||||
|
Manage membership tiers and pricing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateForm(true)}
|
onClick={() => setShowCreateForm(true)}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 20px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#28a745',
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer'
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create New Tier
|
Create New Tier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
|
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
|
||||||
{tiers.map((tier) => (
|
{tiers.map((tier) => (
|
||||||
<div
|
<div
|
||||||
@@ -197,6 +233,8 @@ const MembershipTiers: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
29
frontend/src/services/featureFlagService.ts
Normal file
29
frontend/src/services/featureFlagService.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export interface FeatureFlag {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureFlags {
|
||||||
|
flags: { [key: string]: any };
|
||||||
|
enabled_flags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const featureFlagService = {
|
||||||
|
async getAllFlags(): Promise<FeatureFlags> {
|
||||||
|
const response = await api.get('/feature-flags/flags');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFlag(flagName: string): Promise<FeatureFlag> {
|
||||||
|
const response = await api.get(`/feature-flags/flags/${flagName}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadFlags(): Promise<{ message: string }> {
|
||||||
|
const response = await api.post('/feature-flags/flags/reload');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -14,6 +14,10 @@ export interface LoginData {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordData {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -62,10 +66,6 @@ export interface Payment {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForgotPasswordData {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResetPasswordData {
|
export interface ResetPasswordData {
|
||||||
token: string;
|
token: string;
|
||||||
new_password: string;
|
new_password: string;
|
||||||
@@ -83,6 +83,14 @@ export interface MembershipCreateData {
|
|||||||
auto_renew: boolean;
|
auto_renew: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MembershipTierUpdateData {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
annual_fee?: number;
|
||||||
|
benefits?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MembershipUpdateData {
|
export interface MembershipUpdateData {
|
||||||
status?: string;
|
status?: string;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
@@ -111,12 +119,54 @@ export interface MembershipTierCreateData {
|
|||||||
benefits?: string;
|
benefits?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MembershipTierUpdateData {
|
export interface Event {
|
||||||
name?: string;
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
event_date: string;
|
||||||
|
event_time: string | null;
|
||||||
|
location: string | null;
|
||||||
|
max_attendees: number | null;
|
||||||
|
status: string;
|
||||||
|
created_by: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
rsvp_status?: string; // Current user's RSVP status
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventCreateData {
|
||||||
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
annual_fee?: number;
|
event_date: string;
|
||||||
benefits?: string;
|
event_time?: string;
|
||||||
is_active?: boolean;
|
location?: string;
|
||||||
|
max_attendees?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventUpdateData {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
event_date?: string;
|
||||||
|
event_time?: string;
|
||||||
|
location?: string;
|
||||||
|
max_attendees?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventRSVP {
|
||||||
|
id: number;
|
||||||
|
event_id: number;
|
||||||
|
user_id: number;
|
||||||
|
status: string;
|
||||||
|
attended: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventRSVPData {
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
@@ -175,6 +225,11 @@ export const userService = {
|
|||||||
const response = await api.put(`/users/${userId}`, data);
|
const response = await api.put(`/users/${userId}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteUser(userId: number): Promise<{ message: string }> {
|
||||||
|
const response = await api.delete(`/users/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const membershipService = {
|
export const membershipService = {
|
||||||
@@ -245,3 +300,45 @@ export const paymentService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const eventService = {
|
||||||
|
async getAllEvents(): Promise<Event[]> {
|
||||||
|
const response = await api.get('/events/');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUpcomingEvents(): Promise<Event[]> {
|
||||||
|
const response = await api.get('/events/upcoming');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createEvent(data: EventCreateData): Promise<Event> {
|
||||||
|
const response = await api.post('/events/', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEvent(eventId: number, data: EventUpdateData): Promise<Event> {
|
||||||
|
const response = await api.put(`/events/${eventId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEvent(eventId: number): Promise<{ message: string }> {
|
||||||
|
const response = await api.delete(`/events/${eventId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEventRSVPs(eventId: number): Promise<EventRSVP[]> {
|
||||||
|
const response = await api.get(`/events/${eventId}/rsvps`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createOrUpdateRSVP(eventId: number, data: EventRSVPData): Promise<EventRSVP> {
|
||||||
|
const response = await api.post(`/events/${eventId}/rsvp`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMyRSVPs(): Promise<EventRSVP[]> {
|
||||||
|
const response = await api.get('/events/my-rsvps');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ export default defineConfig({
|
|||||||
host: true,
|
host: true,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
allowedHosts: ['sasaprod', 'localhost', 'members.sasalliance.org'],
|
allowedHosts: process.env.VITE_ALLOWED_HOSTS ? process.env.VITE_ALLOWED_HOSTS.split(',') : ['sasaprod', 'localhost'],
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: true
|
usePolling: true
|
||||||
},
|
},
|
||||||
hmr: {
|
hmr: {
|
||||||
clientPort: 3500
|
clientPort: 8050
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
Reference in New Issue
Block a user