Compare commits

...

9 Commits

Author SHA1 Message Date
James Pattinson
e1659c07ea Arch changes and feature flags 2025-11-23 15:46:51 +00:00
James Pattinson
6f1d09cd77 Using alembic 2025-11-22 21:18:43 +00:00
James Pattinson
b8f2d12011 Square enhancements 2025-11-13 17:41:28 +00:00
James Pattinson
dac8b43915 Event editing 2025-11-12 21:04:00 +00:00
James Pattinson
9edfe6aa62 Layout tweaks 2025-11-12 20:55:24 +00:00
James Pattinson
107c208746 Basic event management 2025-11-12 18:08:11 +00:00
James Pattinson
e5fdd0ecb8 Mobile improvements 2025-11-12 16:47:21 +00:00
James Pattinson
0f74333a22 Square Payments 2025-11-12 16:09:38 +00:00
James Pattinson
be2426c078 Tweak to dev/prod switch 2025-11-11 17:17:18 +00:00
43 changed files with 4700 additions and 674 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,56 +95,41 @@ 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
# Stop all services
docker-compose down
# Check which services are running
docker-compose ps
``` ```
### Profile Details ### Stopping Services
```bash
- **`dev` profile**: Includes the `frontend` service (Vite dev server) # Stop all services
- **`prod` profile**: Includes the `frontend-prod` service (Nginx) docker compose -f docker-compose.yml -f docker-compose.dev.yml down
- **Default**: No frontend service runs unless you specify a profile docker compose -f docker-compose.yml -f docker-compose.prod.yml down
```
### 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
@@ -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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Generic single-database configuration.

97
backend/alembic/env.py Normal file
View 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()

View 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"}

View File

@@ -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")

View File

@@ -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

View File

@@ -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"])

View 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

View 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"}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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",
] ]

View 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

View File

@@ -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

View 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()

View 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()

View File

@@ -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

View File

@@ -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());

View File

@@ -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:

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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,19 +13,21 @@ import './App.css';
const App: React.FC = () => { const App: React.FC = () => {
return ( return (
<BrowserRouter> <FeatureFlagProvider>
<Routes> <BrowserRouter>
<Route path="/" element={<Navigate to="/login" />} /> <Routes>
<Route path="/register" element={<Register />} /> <Route path="/" element={<Navigate to="/login" />} />
<Route path="/login" element={<Login />} /> <Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/email-templates" element={<EmailTemplates />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/membership-tiers" element={<MembershipTiers />} /> <Route path="/email-templates" element={<EmailTemplates />} />
<Route path="/bounce-management" element={<BounceManagement />} /> <Route path="/membership-tiers" element={<MembershipTiers />} />
</Routes> <Route path="/bounce-management" element={<BounceManagement />} />
</BrowserRouter> </Routes>
</BrowserRouter>
</FeatureFlagProvider>
); );
}; };

View 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;

View File

@@ -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,45 +38,74 @@ 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 startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Create membership
const membershipData: MembershipCreateData = {
tier_id: selectedTier.id,
start_date: startDate,
end_date: endDate,
auto_renew: false
};
const membership = await membershipService.createMembership(membershipData);
// Create fake payment
const paymentData: PaymentCreateData = { const paymentData: PaymentCreateData = {
amount: selectedTier.annual_fee, amount: selectedTier.annual_fee,
payment_method: 'dummy', payment_method: 'cash',
membership_id: membership.id, membership_id: createdMembershipId,
notes: `Fake payment for ${selectedTier.name} membership` notes: `Cash payment for ${selectedTier.name} membership`
}; };
await paymentService.createPayment(paymentData); await paymentService.createPayment(paymentData);
setStep('confirm'); setStep('confirm');
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || 'Failed to create membership'); setError(err.response?.data?.detail || 'Failed to record payment');
} finally { } finally {
setLoading(false); 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 endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const membershipData: MembershipCreateData = {
tier_id: selectedTier.id,
start_date: startDate,
end_date: endDate,
auto_renew: false
};
const membership = await membershipService.createMembership(membershipData);
setCreatedMembershipId(membership.id);
setLoading(false);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to create membership');
setLoading(false);
}
}
// For Square, just set the payment method - membership created after successful payment
};
const handleConfirm = () => { const handleConfirm = () => {
onMembershipCreated(); onMembershipCreated();
}; };
@@ -144,49 +179,161 @@ 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' }}>
</div> <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>
<div style={{ textAlign: 'center' }}> {isEnabled('CASH_PAYMENT_ENABLED') && (
<button <button
type="button" className="btn btn-secondary"
className="btn btn-primary" onClick={() => handlePaymentMethodSelect('cash')}
onClick={handlePayment} disabled={loading}
disabled={loading} style={{
style={{ marginRight: '10px' }} padding: '16px',
> textAlign: 'left',
{loading ? 'Processing...' : 'Complete Cash Payment'} display: 'flex',
</button> justifyContent: 'space-between',
<button alignItems: 'center'
type="button" }}
className="btn btn-secondary" >
onClick={() => setStep('select')} <div>
disabled={loading} <strong>Cash Payment</strong>
> <div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
Back Pay in person or by check
</button> </div>
</div> </div>
<span></span>
</button>
)}
</div>
<div style={{ marginTop: '20px', textAlign: 'center' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => setStep('select')}
disabled={loading}
>
Back
</button>
</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> </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>
)} )}

View File

@@ -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>

View 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;

View 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>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View File

@@ -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,31 +84,65 @@ const MembershipTiers: React.FC = () => {
} }
return ( return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}> <div style={{
minHeight: '100vh',
backgroundColor: '#f8f9fa',
padding: '20px'
}}>
<div style={{ <div style={{
display: 'flex', maxWidth: '1200px',
justifyContent: 'space-between', margin: '0 auto',
alignItems: 'center', backgroundColor: 'white',
marginBottom: '30px' borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}> }}>
<h1 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h1> <div style={{
<button backgroundColor: '#007bff',
onClick={() => setShowCreateForm(true)} color: 'white',
style={{ padding: '20px',
padding: '10px 20px', display: 'flex',
backgroundColor: '#28a745', justifyContent: 'space-between',
color: 'white', alignItems: 'center'
border: 'none', }}>
borderRadius: '4px', <div>
cursor: 'pointer', <h1 style={{ margin: 0, fontSize: '24px' }}>Membership Tiers Management</h1>
fontSize: '14px' <p style={{ margin: '5px 0 0 0', opacity: 0.9 }}>
}} Manage membership tiers and pricing
> </p>
Create New Tier </div>
</button> <div style={{ display: 'flex', gap: '10px' }}>
</div> <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
onClick={() => setShowCreateForm(true)}
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'
}}
>
Create New Tier
</button>
</div>
</div>
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}> <div style={{ padding: '20px' }}>
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
{tiers.map((tier) => ( {tiers.map((tier) => (
<div <div
key={tier.id} key={tier.id}
@@ -196,6 +232,8 @@ const MembershipTiers: React.FC = () => {
onCancel={handleCancelEdit} onCancel={handleCancelEdit}
/> />
)} )}
</div>
</div>
</div> </div>
); );
}; };

View 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;
}
};

View File

@@ -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;
}
};

View File

@@ -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': {