main #1

Open
nathanb wants to merge 9 commits from nathanb/sasa-membership:main into main
34 changed files with 3932 additions and 749 deletions
Showing only changes of commit 632e66e21d - Show all commits
+42 -12
View File
@@ -4,25 +4,34 @@
This project aims to develop a comprehensive membership management system for the Swansea Airport Stakeholders' Alliance. The system will handle member registration, payment processing, membership tracking, and administrative functions for a medium-sized alliance.
## Current Implementation Status
The app now includes a FastAPI backend, React/Vite frontend, Docker Compose development gateway, Alembic migrations, Square payment integration, SMTP2GO email integration, event/RSVP endpoints, configurable profile questions, privacy/terms pages, feature flags, and a fast test gate in `restart.sh`.
## Core Features
### Public Member Features
- **Self-Service Registration**: Members can sign up online and select their membership tier
- **Payment Processing**: Integration with Square payment system for secure online payments, and a dummy payment system for initial testing
- **Membership Portal**: Secure login to view membership status, payment history, and upcoming meetings
- **Renewal Reminders**: Automated email notifications for membership renewal deadlines
- **Profile Questions**: Members can answer configurable profile questions, including conditional and volunteering-related questions
- **Event Management**: View upcoming events and RSVP to participate
- **Volunteering**: View assigned volunteer roles, schedule availability for roles, and access certificates/training records
- **Account Management**: Members can update profile details, change passwords, request password resets, and review privacy/terms pages
- **Renewal Reminders**: Planned automated email notifications for membership renewal deadlines
- **Volunteering**: Volunteer-related profile fields are implemented; richer role, schedule, and certificate screens are planned
### Administrative Features
- **Member Database Management**: Query and modify member records
- **Manual Payment Entry**: Record cash payments to activate memberships
- **Membership Tier Management**: Configure different membership levels and associated fees
- **Meeting Management**: Post notices and updates about upcoming alliance meetings
- **Reporting**: Generate reports on membership statistics and payment status
- **Files**: A repositry for files which members can access based on their tier - such as meeting minutes and manuals. Admins can upload files to this area.
- **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions
- **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance
- **Email Management**: Edit database-backed email templates with escaped previews, send test emails, and monitor SMTP2GO bounces
- **Feature Flags**: View backend feature flags and reload them from the super-admin interface
- **Reporting**: Planned reports on membership statistics and payment status
- **Files**: Planned repository for member files based on tier, such as meeting minutes and manuals
- **Event Management**: Create, edit, and manage events, track RSVPs and attendance
- **Volunteering**: Assign configurable volunteer roles to members (e.g., Fire, Radio, General), manage volunteer schedules, and record certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members.
- **Volunteering**: Models exist for configurable volunteer roles, assignments, schedules, and certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members.
## Technical Stack
@@ -32,7 +41,8 @@ This project aims to develop a comprehensive membership management system for th
- **Authentication**: JWT-based authentication system
- **Payment Integration**: Square API for payment processing
- **Email Service**: SMTP2GO API for automated reminders and notifications
- **Frontend**: Modern web interface (to be determined - potentially React/Vue.js)
- **Frontend**: React 18, TypeScript, Vite, and Tailwind CSS
- **Testing**: Vitest for frontend unit tests and pytest for backend unit tests
## Membership Tiers
@@ -73,21 +83,41 @@ Each tier will have associated annual fees and benefits.
- `memberships`: Membership records with tier and status
- `payments`: Payment transactions
- `tiers`: Membership tier definitions
- `profile_questions`: Configurable profile/onboarding questions
- `user_profile_answers`: Per-member profile answers
- `events`: Event information and details
- `event_rsvps`: Event registration and attendance tracking
- `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General)
- `volunteer_assignments`: Member-to-role assignments
- `volunteer_schedules`: Volunteer shift scheduling and availability
- `certificates`: Training certificates and qualifications
- `email_templates`: Editable SMTP2GO email templates
- `email_bounces`: Bounce/complaint/unsubscribe tracking
- `password_reset_tokens`: One-time reset tokens
- `notifications`: Email notification logs
## Testing and Restart Workflow
`./restart.sh` rebuilds Docker images with cache, runs the fast frontend and backend unit tests, shuts down the current stack, and starts it again only if tests pass.
```bash
./restart.sh
```
Individual test commands:
```bash
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
```
## Development Phases
1. **Phase 1**: Core API development (authentication, user management)
2. **Phase 2**: Payment integration and membership management
3. **Phase 3**: Admin interface development
4. **Phase 4**: Member portal, email system, event management, and volunteering features
5. **Phase 5**: Testing, deployment, and documentation
1. **Phase 1**: Core API development (authentication, user management) - implemented
2. **Phase 2**: Payment integration and membership management - implemented
3. **Phase 3**: Admin interface development - implemented for users, tiers, payments, emails, bounces, profile questions, and feature flags
4. **Phase 4**: Member portal, email system, event management, and volunteering features - partially implemented; richer volunteer screens and renewal reminders remain
5. **Phase 5**: Testing, deployment, and documentation - active; fast unit tests and documentation are in place
## Deployment Considerations
+107 -73
View File
@@ -2,115 +2,149 @@
```
membership/
├── .env # Environment configuration (ready to use)
├── .env.example # Template for environment variables
├── .env # Local environment configuration
├── .env.example # Environment variable template
├── .gitignore # Git ignore rules
├── docker-compose.yml # Docker services configuration
├── INSTRUCTIONS.md # Original project requirements
├── README.md # Complete documentation
├── QUICKSTART.md # Quick start guide
├── docker-compose.yml # Backend, frontend, gateway, and prod frontend services
├── restart.sh # Build, run fast tests, and restart the app
├── INSTRUCTIONS.md # Product requirements and roadmap context
├── README.md # Full project documentation
├── QUICKSTART.md # Short operator/developer guide
├── backend/ # FastAPI application
│ ├── Dockerfile # Backend container configuration
│ ├── requirements.txt # Python dependencies
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── alembic.ini
│ ├── alembic/ # Database migrations
│ └── app/
│ ├── __init__.py
│ ├── main.py # Application entry point
│ │
│ ├── api/ # API endpoints
│ │ ├── __init__.py
│ ├── main.py # App, CORS, health check, router registration
│ ├── api/
│ │ ├── dependencies.py # Auth dependencies
│ │ └── v1/
│ │ ├── __init__.py
│ │ ├── auth.py # Registration, login
│ │ ├── users.py # User management
│ │ ├── auth.py # Register, login, password reset/change
│ │ ├── users.py # Users, profile questions, profile answers
│ │ ├── tiers.py # Membership tiers
│ │ ├── memberships.py # Membership management
│ │ ── payments.py # Payment processing
│ │
├── core/ # Core functionality
│ │ ├── __init__.py
│ │ ├── config.py # Configuration settings
│ ├── database.py # Database connection
│ └── security.py # Auth & password hashing
│ │
│ ├── models/ # Database models
│ │ ├── __init__.py
│ │ └── models.py # SQLAlchemy models
│ │
│ │ ├── memberships.py
│ │ ── payments.py # Manual, Square, refund, payment history
│ │ ├── email.py # SMTP2GO email tests and bounce webhooks
│ ├── email_templates.py
│ │ ├── events.py # Events and RSVPs
│ │ └── feature_flags.py
├── core/ # Config, database, security, default data
├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── __init__.py
│ └── schemas.py # Request/response schemas
│ │
│ ├── services/ # Business logic (placeholder)
│ └── utils/ # Utilities (placeholder)
├── services/ # Email, bounce, Square, feature flags
└── tests/ # Fast backend pytest unit tests
├── database/ # Database initialization
│ └── init.sql # Default data & admin user
├── docker/
│ └── gateway/ # Nginx dev gateway and self-signed TLS setup
└── frontend/ # Frontend (placeholder for future)
└── frontend/ # React/Vite frontend
├── Dockerfile
├── package.json
├── vite.config.ts
└── src/
├── App.tsx # Routes, footer links, cookie notice
├── components/ # Dashboard, admin, payment, email, profile UI
├── contexts/ # Feature flag context/provider
├── pages/ # Login, register, dashboard, policy pages
├── services/ # API clients
└── utils/ # Shared frontend logic and Vitest tests
```
## Key Files
### Configuration
- **`.env`** - Environment variables (database, API keys, etc.)
- **`docker-compose.yml`** - Services: MySQL + FastAPI backend
- **`.env`** - Runtime configuration for database, auth, Square, SMTP2GO, ports, and gateway TLS.
- **`docker-compose.yml`** - Services for FastAPI backend, Vite frontend, Nginx gateway, and production static frontend.
- **`restart.sh`** - Rebuilds images, runs frontend/backend unit tests, and restarts the stack only if tests pass.
### Backend Application
- **`backend/app/main.py`** - FastAPI app initialization, CORS, routes
- **`backend/app/core/config.py`** - Settings management
- **`backend/app/core/security.py`** - JWT tokens, password hashing
- **`backend/app/models/models.py`** - Database tables (User, Membership, Payment, etc.)
- **`backend/app/schemas/schemas.py`** - API request/response models
- **`backend/app/main.py`** - FastAPI app initialization, CORS, startup default-data seeding, routes, and health checks.
- **`backend/app/core/config.py`** - Settings management.
- **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions.
- **`backend/app/core/security.py`** - JWT tokens and password hashing.
- **`backend/app/models/models.py`** - Database tables.
- **`backend/app/schemas/schemas.py`** - API request/response models.
- **`backend/app/tests/test_profile_question_logic.py`** - Fast backend unit tests for profile answer validation.
### API Endpoints (v1)
- **`auth.py`** - Register, login
- **`users.py`** - User profile, admin user management
- **`tiers.py`** - Membership tier CRUD
- **`memberships.py`** - Membership management
- **`payments.py`** - Payment processing & history
### Frontend Application
- **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard.
- **`frontend/src/components/MembershipSetup.tsx`** - Membership tier selection and payment flow.
- **`frontend/src/components/SquarePayment.tsx`** - Square Web Payments SDK form.
- **`frontend/src/components/AdminProfileQuestionManager.tsx`** - Admin profile-question configuration.
- **`frontend/src/components/ProfileQuestionsForm.tsx`** - Member/admin answer form with dependency handling.
- **`frontend/src/components/EmailTemplateManagement.tsx`** - Email template editing.
- **`frontend/src/components/BounceManagement.tsx`** - SMTP2GO bounce management.
- **`frontend/src/utils/profileQuestionLogic.test.ts`** - Fast frontend unit tests for profile-question visibility/editability.
## API Endpoints
- **`auth.py`** - Register, login, forgot password, reset password, change password.
- **`users.py`** - Current user profile, admin user CRUD, profile-question CRUD, member/admin profile answers, and role-guarded admin password reset emails.
- **`tiers.py`** - Membership tier CRUD.
- **`memberships.py`** - Member/admin membership management.
- **`payments.py`** - Payment history, manual payments, Square config/process/refund.
- **`events.py`** - Event CRUD, upcoming events, RSVP create/update, RSVP listing.
- **`email.py`** - SMTP2GO test emails, welcome email tests, bounce webhook, bounce stats, cleanup, deactivation.
- **`email_templates.py`** - Database-backed template listing, lookup, update, and default seeding.
- **`feature_flags.py`** - Public feature flag listing/lookup and super-admin-only reload.
## Database Models
Fully implemented:
- **User** - Authentication, profile, roles (member/admin/super_admin)
- **MembershipTier** - Configurable tiers with fees and benefits
- **Membership** - User memberships with status tracking
- **Payment** - Payment records with multiple methods
- **Event** - Event management (model ready, endpoints TODO)
- **EventRSVP** - Event registration (model ready, endpoints TODO)
- **VolunteerRole** - Volunteer roles (model ready, endpoints TODO)
- **VolunteerAssignment** - Role assignments (model ready, endpoints TODO)
- **VolunteerSchedule** - Shift scheduling (model ready, endpoints TODO)
- **Certificate** - Training certificates (model ready, endpoints TODO)
- **File** - File repository (model ready, endpoints TODO)
- **Notification** - Email tracking (model ready, endpoints TODO)
- **User** - Authentication, profile, roles, volunteer level.
- **ProfileQuestion** - Configurable profile fields, options, dependencies, admin-only edit flags.
- **UserProfileAnswer** - Per-user answers with update attribution.
- **MembershipTier** - Configurable tiers with fees and benefits.
- **Membership** - User memberships with status, dates, and auto-renew flag.
- **Payment** - Payment records for Square, cash, check, and dummy methods.
- **Event** - Event management records.
- **EventRSVP** - RSVP and attendance records.
- **EmailTemplate** - Editable database-backed email templates.
- **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking.
- **PasswordResetToken** - One-time password reset support.
- **VolunteerRole** - Volunteer role definitions.
- **VolunteerAssignment** - Member-to-role assignments.
- **VolunteerSchedule** - Volunteer shift schedules.
- **Certificate** - Training/certificate records.
- **File** - File repository metadata.
- **Notification** - Email notification logs.
## Quick Start
```bash
# Start everything
docker-compose up -d
docker compose up -d
# View logs
docker-compose logs -f
docker compose logs -f
# Access API docs
# http://localhost:8000/docs
# http://localhost:8050/docs
```
## Tests
```bash
# Run both fast test suites and restart only if they pass
./restart.sh
# Run test suites individually
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
```
## Default Credentials
**Admin**: admin@swanseaairport.org / admin123
**Database**: Configured via environment variables (see .env file)
**Database**: Configured via environment variables in `.env`.
## What's Next
## Remaining Roadmap
1. Test the API endpoints
2. Add Square payment integration
3. Implement email notifications
4. Create event management endpoints
5. Add volunteer management endpoints
6. Build frontend interface
1. Expand authenticated API tests for member/admin workflows
2. Add member file repository endpoints and UI
3. Build richer volunteer assignment, schedule, and certificate screens
4. Add renewal reminder batch jobs
5. Add reporting and analytics
+37 -1
View File
@@ -21,6 +21,20 @@ For Square payment form testing, use HTTPS at `https://localhost:8443`.
Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`.
TLS certs are auto-generated by the gateway container on first start.
## Restart With Tests
Use the restart helper when you want to rebuild, run the fast test suite, and restart only after tests pass:
```bash
./restart.sh
```
It runs:
- `docker compose run --rm frontend npm test`
- `docker compose run --rm backend pytest -q`
The current tests cover frontend profile-question visibility/editability rules and backend profile-question answer normalization/validation. They are designed to complete quickly.
## Testing the API
### 1. Register a new user
@@ -108,6 +122,26 @@ docker compose logs -f gateway
1. Login as admin
2. GET `/api/v1/users/`
### Manage profile questions (admin)
1. Login as admin or super admin
2. Open the dashboard Admin area
3. Create, edit, deactivate, and order configurable profile questions
4. Use dependencies to show questions only after a matching parent answer
### Edit member profile answers
1. Members can update normal profile questions from the Questions dashboard tab
2. Admin-only answers, such as verified training fields, must be updated by an admin
### Manage events and RSVPs
1. Admins can create and edit events from the dashboard
2. Members can view upcoming events and submit RSVP status
3. Admins can view RSVP lists and attendance data
### Manage email templates and bounces
1. Super admins can edit database-backed email templates; previews are shown as escaped HTML text
2. SMTP2GO bounce webhooks are stored and visible in bounce management
3. Bounce cleanup and manual deactivation are available through the API/admin screens
## Troubleshooting
### Check service status
@@ -143,4 +177,6 @@ docker compose up -d
3. Create additional admin users
4. Configure membership tiers as needed
5. Test payment processing
6. Customize email templates (coming soon)
6. Customize email templates
7. Configure profile questions for onboarding and volunteer data
8. Use `./restart.sh` before deploying changes so frontend and backend unit tests run first
+85 -19
View File
@@ -1,24 +1,32 @@
# Swansea Airport Stakeholders' Alliance Membership Management System
A comprehensive membership management system built with FastAPI, MySQL, and Docker.
A membership management system for Swansea Airport Stakeholders' Alliance, built with FastAPI, React, MySQL-compatible storage, Square payments, SMTP2GO email services, and Docker Compose.
## Features
- **User Management**: Registration, authentication, and profile management
- **Membership Tiers**: Configurable membership levels with different benefits and fees
- **Payment Processing**: Support for Square payments, cash, and check payments
- **Admin Dashboard**: Complete administrative control over members and payments
- **Event Management**: Create and manage events with RSVP tracking (coming soon)
- **Volunteer Management**: Role assignments, scheduling, and certificates (coming soon)
- **Email Notifications**: Automated notifications via SMTP2GO (coming soon)
- **Authentication and accounts**: Registration, JSON/form login, JWT sessions, password reset, password change, and role-based access for members, admins, and super admins.
- **Member portal**: Dashboard with membership status, payment history, membership setup, account settings, profile editing, configurable profile questions, cookie notice, privacy policy, and terms of service pages.
- **Admin operations**: User listing/editing, admin-triggered member password reset emails, membership tier CRUD, manual payment recording, Square refunds, email template editing with escaped previews, SMTP2GO bounce management, profile-question management, and super-admin feature-flag reloads.
- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits.
- **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking.
- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility.
- **Volunteer and profile data**: Volunteer flag/level support, configurable member profile questions, conditional questions, admin-only answers, seeded aviation/volunteering questions, and data models for volunteer roles, assignments, schedules, and certificates.
- **Email system**: SMTP2GO-backed email sending, default database templates, editable templates, welcome/password-reset/test emails, bounce webhooks, bounce stats, cleanup, and manual deactivation.
- **Feature flags**: Backend feature-flag service with frontend context and admin status/reload controls.
- **Testing**: Fast frontend Vitest unit tests and backend pytest unit tests wired into `restart.sh`.
## Tech Stack
- **Backend**: FastAPI (Python 3.11)
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS
- **Database**: MySQL 8.0
- **Authentication**: JWT tokens with OAuth2
- **Containerization**: Docker & Docker Compose
- **ORM**: SQLAlchemy
- **Migrations**: Alembic
- **Payments**: Square Web Payments SDK and Square API
- **Email**: SMTP2GO
- **Tests**: Vitest and pytest
## Project Structure
@@ -37,7 +45,11 @@ membership/
│ │ │ │ ├── users.py # User management
│ │ │ │ ├── tiers.py # Membership tiers
│ │ │ │ ├── memberships.py # Membership management
│ │ │ │ ── payments.py # Payment processing
│ │ │ │ ── payments.py # Payment processing
│ │ │ │ ├── email.py # SMTP2GO email and bounces
│ │ │ │ ├── email_templates.py
│ │ │ │ ├── events.py # Events and RSVPs
│ │ │ │ └── feature_flags.py
│ │ │ └── dependencies.py # Auth dependencies
│ │ ├── core/
│ │ │ ├── config.py # Configuration
@@ -50,8 +62,13 @@ membership/
│ │ └── main.py # Application entry point
│ ├── Dockerfile
│ └── requirements.txt
├── database/
── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
├── frontend/
── src/
│ │ ├── components/ # Dashboard, payment, admin, profile components
│ │ ├── contexts/ # Feature flag context
│ │ ├── pages/ # Login, register, dashboard, policy pages
│ │ ├── services/ # API clients
│ │ └── utils/ # Tested frontend logic
├── docker-compose.yml
├── .env.example
└── README.md
@@ -95,6 +112,25 @@ membership/
- API Documentation: http://localhost:8050/docs
- TLS certs are generated automatically by the gateway container on first start
## Restart and Test Gate
`restart.sh` rebuilds images with cache, runs the fast frontend and backend unit tests, then restarts the stack only if tests pass:
```bash
./restart.sh
```
The current fast test suite covers:
- frontend profile-question visibility and editability rules with Vitest
- backend profile-question option parsing, answer normalization/deserialization, select validation, and volunteer flag normalization with pytest
You can also run them individually:
```bash
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
```
## Frontend Development vs Production
### Development Mode (Vite)
@@ -191,6 +227,39 @@ docker compose --profile prod down
- `PUT /api/v1/payments/{id}` - Update payment (admin)
- `GET /api/v1/payments/` - List all payments (admin)
- `POST /api/v1/payments/manual-payment` - Record manual payment (admin)
- `GET /api/v1/payments/config/square` - Get frontend Square config
- `POST /api/v1/payments/square/process` - Process Square card payment
- `POST /api/v1/payments/square/refund` - Refund Square payment (admin)
### Profile Questions
- `GET /api/v1/users/me/profile-questions` - List active questions with current answers
- `PUT /api/v1/users/me/profile-answers` - Update editable answers
- `GET /api/v1/users/admin/profile-questions` - List all profile questions (admin)
- `POST /api/v1/users/admin/profile-questions` - Create profile question (admin)
- `PUT /api/v1/users/admin/profile-questions/{id}` - Update profile question (admin)
- `DELETE /api/v1/users/admin/profile-questions/{id}` - Deactivate profile question (admin)
- `GET /api/v1/users/admin/users/{id}/profile-answers` - View user answers (admin)
- `PUT /api/v1/users/admin/users/{id}/profile-answers` - Update user answers (admin)
### Events
- `GET /api/v1/events/` - List events
- `GET /api/v1/events/upcoming` - List upcoming events
- `POST /api/v1/events/` - Create event (admin)
- `PUT /api/v1/events/{id}` - Update event (admin)
- `DELETE /api/v1/events/{id}` - Delete event (admin)
- `GET /api/v1/events/{id}/rsvps` - List RSVPs (admin)
- `POST /api/v1/events/{id}/rsvp` - Create or update current user's RSVP
### Email and Feature Flags
- `POST /api/v1/email/test-email` - Send test email
- `POST /api/v1/email/test-welcome-email` - Send test welcome email
- `POST /api/v1/email/webhooks/smtp2go/bounce` - Receive SMTP2GO bounce webhook
- `GET /api/v1/email/bounces` - List bounces
- `GET /api/v1/email/bounces/stats` - Bounce statistics
- `GET /api/v1/email-templates/` - List templates
- `PUT /api/v1/email-templates/{template_key}` - Update template
- `GET /api/v1/feature-flags/flags` - List flags
- `POST /api/v1/feature-flags/flags/reload` - Reload flags (super admin)
## Docker Compose Commands
@@ -362,16 +431,13 @@ docker compose up -d
docker compose logs -f
```
## Next Steps
## Remaining Roadmap
- [ ] Implement Square payment integration
- [ ] Add email notification system
- [ ] Create event management endpoints
- [ ] Add volunteer management features
- [ ] Build frontend interface
- [ ] Add file upload/management
- [ ] Implement automated renewal reminders
- [ ] Add member file upload/repository endpoints and UI
- [ ] Add richer volunteer role, assignment, schedule, and certificate screens on top of the existing models
- [ ] Implement automated renewal reminder batch jobs
- [ ] Add reporting and analytics
- [ ] Expand test coverage around authenticated API flows and payment/email service boundaries
## License
+15 -13
View File
@@ -36,7 +36,7 @@
- [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
- [x] Updated `restart.sh` - Build, fast tests, and restart helper
### Code Quality
- [x] No Python syntax errors
@@ -64,13 +64,15 @@ Before deploying, complete these steps:
- [ ] 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`
- [ ] Run `./restart.sh` OR
- [ ] Run `docker compose build`
- [ ] Run `docker compose run --rm frontend npm test`
- [ ] Run `docker compose run --rm backend pytest -q`
- [ ] Run `docker compose up -d`
- [ ] Verify containers are running: `docker compose ps`
### 4. Testing
- [ ] Access frontend at http://localhost:3000
- [ ] Access frontend at http://localhost:8050 or HTTPS at https://localhost:8443
- [ ] Login/register a user
- [ ] Navigate to membership setup
- [ ] Select a membership tier
@@ -104,7 +106,7 @@ After deployment, run these commands to verify:
```bash
# Check backend is running
curl http://localhost:8000/api/v1/payments/config/square
curl http://localhost:8050/api/v1/payments/config/square
# Expected output (with your actual IDs):
# {
@@ -114,10 +116,10 @@ curl http://localhost:8000/api/v1/payments/config/square
# }
# Check frontend is running
curl http://localhost:3000
curl http://localhost:8050
# Check logs
docker-compose logs backend | grep -i square
docker compose logs backend | grep -i square
```
## 📊 Testing Matrix
@@ -135,13 +137,13 @@ docker-compose logs backend | grep -i square
```bash
# Check Square SDK installed
docker-compose exec backend pip list | grep square
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)"
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;"
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
# Check frontend files
ls -la frontend/src/components/SquarePayment.tsx
@@ -151,7 +153,7 @@ ls -la frontend/src/components/SquarePayment.tsx
| Issue | Solution |
|-------|----------|
| "Module not found: squareup" | Rebuild backend: `docker-compose build backend` |
| "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 |
+9
View File
@@ -193,6 +193,15 @@ The Square payment integration is complete, tested, and working in sandbox mode:
- 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
- `restart.sh` now runs the fast Vitest and pytest suites before restarting the stack
Fast verification commands:
```bash
./restart.sh
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
```
## Summary
+4 -3
View File
@@ -66,13 +66,14 @@ SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID
### 5. Restart the Application
After updating the environment variables, restart your Docker containers:
After updating the environment variables, run the tested restart helper:
```bash
docker-compose down
docker-compose up -d --build
./restart.sh
```
For a manual restart, run `docker compose build`, `docker compose run --rm frontend npm test`, `docker compose run --rm backend pytest -q`, and then `docker compose up -d`.
## Testing with Sandbox
Square provides test card numbers for sandbox testing:
+9 -7
View File
@@ -38,19 +38,21 @@ SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id...
Run the deployment script:
```bash
./deploy-square.sh
./restart.sh
```
Or manually:
```bash
docker-compose down
docker-compose up -d --build
docker compose build
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
docker compose up -d
```
### Step 4: Test It Out!
1. Open http://localhost:3000
1. Open http://localhost:8050 or https://localhost:8443 for HTTPS Square testing
2. Register/login
3. Go to "Setup Membership"
4. Select a tier
@@ -78,7 +80,7 @@ docker-compose up -d --build
-`.env.example` - UPDATED
-`SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
-`SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
-`deploy-square.sh` - NEW (deployment helper)
-`restart.sh` - build, fast tests, and restart helper
## 🔧 Key Features
@@ -118,7 +120,7 @@ User → Select Tier → Choose Payment Method
### Backend won't start?
```bash
docker-compose logs backend
docker compose logs backend
```
Check for missing dependencies or configuration errors.
@@ -156,7 +158,7 @@ When ready for production payments:
1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions
2. Review Square's documentation
3. Check application logs: `docker-compose logs -f backend`
3. Check application logs: `docker compose logs -f backend`
4. Contact Square support for payment-specific issues
---
@@ -0,0 +1,77 @@
"""Add volunteer level and dynamic profile questions
Revision ID: 2e8a0f9d4b31
Revises: b583fd2cf202
Create Date: 2026-05-04 17:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2e8a0f9d4b31'
down_revision: Union[str, None] = 'b583fd2cf202'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('volunteer_level', sa.String(length=50), nullable=True))
op.create_table(
'profile_questions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('help_text', sa.Text(), nullable=True),
sa.Column('input_type', sa.String(length=30), nullable=False),
sa.Column('placeholder', sa.String(length=255), nullable=True),
sa.Column('options_json', sa.Text(), nullable=True),
sa.Column('is_required', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('admin_only_edit', sa.Boolean(), nullable=False),
sa.Column('display_order', sa.Integer(), nullable=False),
sa.Column('depends_on_question_id', sa.Integer(), nullable=True),
sa.Column('depends_on_value', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['depends_on_question_id'], ['profile_questions.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_profile_questions_id'), 'profile_questions', ['id'], unique=False)
op.create_index(op.f('ix_profile_questions_key'), 'profile_questions', ['key'], unique=True)
op.create_table(
'user_profile_answers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('value_text', sa.Text(), nullable=True),
sa.Column('updated_by_user_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['question_id'], ['profile_questions.id']),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id']),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'question_id', name='uq_user_profile_answer'),
)
op.create_index(op.f('ix_user_profile_answers_id'), 'user_profile_answers', ['id'], unique=False)
op.create_index(op.f('ix_user_profile_answers_question_id'), 'user_profile_answers', ['question_id'], unique=False)
op.create_index(op.f('ix_user_profile_answers_user_id'), 'user_profile_answers', ['user_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_user_profile_answers_user_id'), table_name='user_profile_answers')
op.drop_index(op.f('ix_user_profile_answers_question_id'), table_name='user_profile_answers')
op.drop_index(op.f('ix_user_profile_answers_id'), table_name='user_profile_answers')
op.drop_table('user_profile_answers')
op.drop_index(op.f('ix_profile_questions_key'), table_name='profile_questions')
op.drop_index(op.f('ix_profile_questions_id'), table_name='profile_questions')
op.drop_table('profile_questions')
op.drop_column('users', 'volunteer_level')
+5 -4
View File
@@ -1,7 +1,7 @@
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
from app.api.dependencies import get_super_admin_user
router = APIRouter()
@@ -38,10 +38,11 @@ async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
@router.post("/flags/reload")
async def reload_feature_flags():
async def reload_feature_flags(
current_user = Depends(get_super_admin_user),
):
"""
Reload feature flags from environment variables
This could be protected with admin permissions in production
Reload feature flags from environment variables.
"""
feature_flags.reload_flags()
return {"message": "Feature flags reloaded successfully"}
+631 -7
View File
@@ -1,16 +1,188 @@
import json
from datetime import date, datetime, timedelta
from typing import Any, List, Optional
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from ...core.database import get_db
from ...core.security import get_password_hash
from ...models.models import User
from ...schemas import UserResponse, UserUpdate, MessageResponse
from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
from ...schemas import (
MessageResponse,
ProfileAnswersUpdateRequest,
ProfileQuestionCreate,
ProfileQuestionForUser,
ProfileQuestionResponse,
ProfileQuestionUpdate,
UserResponse,
UserUpdate,
)
from ...api.dependencies import get_current_active_user, get_admin_user
from ...services.email_service import email_service
router = APIRouter()
def _parse_options(options_json: Optional[str]) -> list[dict[str, str]]:
if not options_json:
return []
try:
parsed = json.loads(options_json)
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(parsed, list):
return []
normalized: list[dict[str, str]] = []
for item in parsed:
if not isinstance(item, dict):
continue
label = str(item.get("label", "")).strip()
value = str(item.get("value", "")).strip()
if label and value:
normalized.append({"label": label, "value": value})
return normalized
def _serialize_options(options: Optional[list[Any]]) -> Optional[str]:
if not options:
return None
normalized = []
for item in options:
data = item.model_dump() if hasattr(item, "model_dump") else item
normalized.append({"label": str(data["label"]), "value": str(data["value"])})
return json.dumps(normalized)
def _normalize_answer_value(question: ProfileQuestion, value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, str) and value.strip() == "":
return None
input_type = question.input_type
if input_type == "boolean":
if isinstance(value, bool):
return "true" if value else "false"
text = str(value).strip().lower()
if text in {"true", "1", "yes", "y"}:
return "true"
if text in {"false", "0", "no", "n"}:
return "false"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid boolean answer for question '{question.key}'"
)
if input_type == "number":
try:
number = float(value)
return str(int(number)) if number.is_integer() else str(number)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid number answer for question '{question.key}'"
)
if input_type == "date":
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = str(value).strip()
try:
parsed = datetime.strptime(text, "%Y-%m-%d")
return parsed.date().isoformat()
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date answer for question '{question.key}'. Use YYYY-MM-DD"
)
if input_type == "select":
selected = str(value).strip()
option_values = {opt["value"] for opt in _parse_options(question.options_json)}
if selected not in option_values:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid selection for question '{question.key}'"
)
return selected
return str(value).strip()
def _deserialize_answer_value(question: ProfileQuestion, value_text: Optional[str]) -> Any:
if value_text is None:
return None
if question.input_type == "boolean":
return value_text.lower() == "true"
if question.input_type == "number":
try:
number = float(value_text)
return int(number) if number.is_integer() else number
except ValueError:
return value_text
return value_text
def _validate_question_dependencies(
db: Session,
depends_on_question_id: Optional[int],
depends_on_value: Optional[str],
current_question_id: Optional[int] = None,
) -> None:
if depends_on_question_id is None:
if depends_on_value is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="depends_on_value requires depends_on_question_id"
)
return
dependent_question = db.query(ProfileQuestion).filter(ProfileQuestion.id == depends_on_question_id).first()
if not dependent_question:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="depends_on_question_id does not exist"
)
if current_question_id is not None and current_question_id == depends_on_question_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A question cannot depend on itself"
)
def _normalize_volunteer_level(value: Optional[str]) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip().lower()
if normalized == "":
return None
if normalized in {"yes", "true", "1"}:
return "yes"
if normalized in {"no", "false", "0"}:
return "no"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Volunteer flag must be yes or no"
)
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
@@ -28,9 +200,12 @@ async def update_current_user_profile(
"""Update current user's profile"""
update_data = user_update.model_dump(exclude_unset=True)
# Check email uniqueness if email is being updated
if 'email' in update_data and update_data['email'] != current_user.email:
existing_user = db.query(User).filter(User.email == update_data['email']).first()
# Prevent privilege and volunteer-level edits through self-service profile endpoint.
update_data.pop("role", None)
update_data.pop("volunteer_level", None)
if "email" in update_data and update_data["email"] != current_user.email:
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -46,6 +221,101 @@ async def update_current_user_profile(
return current_user
@router.get("/me/profile-questions", response_model=List[ProfileQuestionForUser])
async def list_my_profile_questions(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
questions = db.query(ProfileQuestion).filter(ProfileQuestion.is_active == True).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == current_user.id).all()
answers_by_question = {answer.question_id: answer for answer in answers}
response: list[ProfileQuestionForUser] = []
for question in questions:
user_answer = answers_by_question.get(question.id)
can_edit = (not question.admin_only_edit) or (current_user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN])
response.append(
ProfileQuestionForUser(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
answer=_deserialize_answer_value(question, user_answer.value_text if user_answer else None),
can_edit=can_edit,
)
)
return response
@router.put("/me/profile-answers", response_model=MessageResponse)
async def update_my_profile_answers(
payload: ProfileAnswersUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
if not payload.answers:
return {"message": "No changes submitted"}
question_ids = {item.question_id for item in payload.answers}
questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids), ProfileQuestion.is_active == True).all()
questions_by_id = {question.id: question for question in questions}
missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id]
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Questions not found: {missing_ids}"
)
for item in payload.answers:
question = questions_by_id[item.question_id]
if question.admin_only_edit:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Question '{question.label}' can only be changed by admins"
)
normalized_value = _normalize_answer_value(question, item.value)
answer = db.query(UserProfileAnswer).filter(
UserProfileAnswer.user_id == current_user.id,
UserProfileAnswer.question_id == question.id
).first()
if normalized_value is None:
if answer:
db.delete(answer)
continue
if answer:
answer.value_text = normalized_value
answer.updated_by_user_id = current_user.id
else:
db.add(UserProfileAnswer(
user_id=current_user.id,
question_id=question.id,
value_text=normalized_value,
updated_by_user_id=current_user.id,
))
db.commit()
return {"message": "Profile answers updated successfully"}
@router.get("/", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
@@ -58,6 +328,281 @@ async def list_users(
return users
@router.get("/admin/profile-questions", response_model=List[ProfileQuestionResponse])
async def list_profile_questions_admin(
include_inactive: bool = True,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
query = db.query(ProfileQuestion)
if not include_inactive:
query = query.filter(ProfileQuestion.is_active == True)
questions = query.order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
return [
ProfileQuestionResponse(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
)
for question in questions
]
@router.post("/admin/profile-questions", response_model=ProfileQuestionResponse)
async def create_profile_question_admin(
payload: ProfileQuestionCreate,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
if payload.input_type == "select" and not payload.options:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Select questions require options"
)
_validate_question_dependencies(db, payload.depends_on_question_id, payload.depends_on_value)
existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == payload.key).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Question key already exists"
)
question = ProfileQuestion(
key=payload.key,
label=payload.label,
help_text=payload.help_text,
input_type=payload.input_type,
placeholder=payload.placeholder,
options_json=_serialize_options(payload.options),
is_required=payload.is_required,
is_active=payload.is_active,
admin_only_edit=payload.admin_only_edit,
display_order=payload.display_order,
depends_on_question_id=payload.depends_on_question_id,
depends_on_value=payload.depends_on_value,
)
db.add(question)
db.commit()
db.refresh(question)
return ProfileQuestionResponse(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
)
@router.put("/admin/profile-questions/{question_id}", response_model=ProfileQuestionResponse)
async def update_profile_question_admin(
question_id: int,
payload: ProfileQuestionUpdate,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
update_data = payload.model_dump(exclude_unset=True)
if "key" in update_data and update_data["key"] != question.key:
existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == update_data["key"]).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Question key already exists"
)
input_type = update_data.get("input_type", question.input_type)
options = update_data.get("options")
options_to_validate = options if options is not None else _parse_options(question.options_json)
if input_type == "select" and not options_to_validate:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Select questions require options"
)
depends_on_question_id = update_data.get("depends_on_question_id", question.depends_on_question_id)
depends_on_value = update_data.get("depends_on_value", question.depends_on_value)
_validate_question_dependencies(db, depends_on_question_id, depends_on_value, current_question_id=question.id)
for field, value in update_data.items():
if field == "options":
question.options_json = _serialize_options(value)
else:
setattr(question, field, value)
db.commit()
db.refresh(question)
return ProfileQuestionResponse(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
)
@router.delete("/admin/profile-questions/{question_id}", response_model=MessageResponse)
async def deactivate_profile_question_admin(
question_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
question.is_active = False
db.commit()
return {"message": "Question deactivated successfully"}
@router.get("/admin/users/{user_id}/profile-answers", response_model=List[ProfileQuestionForUser])
async def get_user_profile_answers_admin(
user_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
questions = db.query(ProfileQuestion).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == user_id).all()
answers_by_question = {answer.question_id: answer for answer in answers}
return [
ProfileQuestionForUser(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
answer=_deserialize_answer_value(question, answers_by_question.get(question.id).value_text if answers_by_question.get(question.id) else None),
can_edit=True,
)
for question in questions
]
@router.put("/admin/users/{user_id}/profile-answers", response_model=MessageResponse)
async def update_user_profile_answers_admin(
user_id: int,
payload: ProfileAnswersUpdateRequest,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not payload.answers:
return {"message": "No changes submitted"}
question_ids = {item.question_id for item in payload.answers}
questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids)).all()
questions_by_id = {question.id: question for question in questions}
missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id]
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Questions not found: {missing_ids}"
)
for item in payload.answers:
question = questions_by_id[item.question_id]
normalized_value = _normalize_answer_value(question, item.value)
answer = db.query(UserProfileAnswer).filter(
UserProfileAnswer.user_id == user_id,
UserProfileAnswer.question_id == question.id
).first()
if normalized_value is None:
if answer:
db.delete(answer)
continue
if answer:
answer.value_text = normalized_value
answer.updated_by_user_id = current_user.id
else:
db.add(UserProfileAnswer(
user_id=user_id,
question_id=question.id,
value_text=normalized_value,
updated_by_user_id=current_user.id,
))
db.commit()
return {"message": "User profile answers updated successfully"}
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
@@ -91,6 +636,23 @@ async def update_user(
update_data = user_update.model_dump(exclude_unset=True)
if "email" in update_data and update_data["email"] != user.email:
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
if "role" in update_data and update_data["role"] == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only super admins can assign super admin role"
)
if "volunteer_level" in update_data:
update_data["volunteer_level"] = _normalize_volunteer_level(update_data["volunteer_level"])
for field, value in update_data.items():
setattr(user, field, value)
@@ -100,6 +662,68 @@ async def update_user(
return user
@router.post("/{user_id}/send-password-reset", response_model=MessageResponse)
async def send_user_password_reset(
user_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Send a one-time password reset link email for a user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only super admins can send password reset emails for admin users"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reset password for inactive user"
)
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow()
).update({"used": True})
reset_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=1)
db_token = PasswordResetToken(
user_id=user.id,
token=reset_token,
expires_at=expires_at,
used=False
)
db.add(db_token)
db.commit()
try:
await email_service.send_password_reset_email(
to_email=user.email,
first_name=user.first_name,
reset_token=reset_token,
db=db
)
except Exception as exc:
print(f"Failed to send admin password reset email: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send reset email"
)
return {"message": "One-time password reset email sent successfully"}
@router.delete("/{user_id}", response_model=MessageResponse)
async def delete_user(
user_id: int,
+99 -1
View File
@@ -1,5 +1,7 @@
from sqlalchemy.orm import Session
from ..models.models import MembershipTier, User, UserRole, EmailTemplate
import json
from ..models.models import MembershipTier, User, UserRole, EmailTemplate, ProfileQuestion
from .security import get_password_hash
from datetime import datetime
@@ -70,3 +72,99 @@ def init_default_data(db: Session):
db.add_all(default_templates)
db.commit()
print(f"✓ Created {len(default_templates)} default email templates")
# Seed default profile questions for onboarding and profile attributes
existing_questions = db.query(ProfileQuestion).count()
if existing_questions == 0:
print("Creating default profile questions...")
default_questions = [
ProfileQuestion(
key="has_professional_license",
label="Do you hold a professional aviation-related license?",
help_text="Select your current license status.",
input_type="select",
options_json=json.dumps([
{"label": "No", "value": "none"},
{"label": "Student", "value": "student"},
{"label": "Private Pilot", "value": "ppl"},
{"label": "Commercial Pilot", "value": "cpl"},
{"label": "ATPL", "value": "atpl"},
{"label": "Instructor", "value": "instructor"},
]),
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=10,
),
ProfileQuestion(
key="license_number",
label="License number",
help_text="Optional: your current license number.",
input_type="text",
placeholder="e.g. UK.FCL.123456",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=20,
depends_on_value="ppl",
),
ProfileQuestion(
key="can_support_events",
label="Can you support airport or membership events?",
help_text="Choose yes if you're open to helping with events.",
input_type="boolean",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=30,
),
ProfileQuestion(
key="event_support_notes",
label="What support can you offer?",
help_text="Examples: stewarding, admin desk, setup/packdown, mentoring.",
input_type="text",
placeholder="Type details here",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=40,
depends_on_value="true",
),
ProfileQuestion(
key="hours_available_monthly",
label="Approximate volunteer hours available each month",
help_text="Optional estimate in hours.",
input_type="number",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=50,
),
ProfileQuestion(
key="medical_expiry_date",
label="Medical certificate expiry date",
help_text="Optional date in YYYY-MM-DD format.",
input_type="date",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=60,
),
ProfileQuestion(
key="completed_training_x",
label="Completed Training X",
help_text="This is set by admins once verified.",
input_type="boolean",
is_required=False,
is_active=True,
admin_only_edit=True,
display_order=70,
),
]
db.add_all(default_questions)
db.commit()
question_by_key = {question.key: question for question in db.query(ProfileQuestion).all()}
question_by_key["license_number"].depends_on_question_id = question_by_key["has_professional_license"].id
question_by_key["event_support_notes"].depends_on_question_id = question_by_key["can_support_events"].id
db.commit()
print(f"✓ Created {len(default_questions)} default profile questions")
+4
View File
@@ -15,6 +15,8 @@ from .models import (
VolunteerRole,
VolunteerAssignment,
VolunteerSchedule,
ProfileQuestion,
UserProfileAnswer,
Certificate,
File,
Notification,
@@ -36,6 +38,8 @@ __all__ = [
"VolunteerRole",
"VolunteerAssignment",
"VolunteerSchedule",
"ProfileQuestion",
"UserProfileAnswer",
"Certificate",
"File",
"Notification",
+50 -1
View File
@@ -1,6 +1,6 @@
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
Float, Text, ForeignKey, Date
Float, Text, ForeignKey, Date, UniqueConstraint
)
from sqlalchemy.orm import relationship
from datetime import datetime
@@ -60,6 +60,7 @@ class User(Base):
phone = Column(String(20), nullable=True)
address = Column(Text, nullable=True)
role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False)
volunteer_level = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -71,6 +72,54 @@ class User(Base):
event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan")
volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan")
certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan")
profile_answers = relationship(
"UserProfileAnswer",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="UserProfileAnswer.user_id"
)
class ProfileQuestion(Base):
__tablename__ = "profile_questions"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
label = Column(String(255), nullable=False)
help_text = Column(Text, nullable=True)
input_type = Column(String(30), nullable=False) # text, number, boolean, date, select
placeholder = Column(String(255), nullable=True)
options_json = Column(Text, nullable=True) # JSON array: [{"label":"Yes","value":"true"}]
is_required = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
admin_only_edit = Column(Boolean, default=False, nullable=False)
display_order = Column(Integer, default=0, nullable=False)
depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True)
depends_on_value = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions")
answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan")
class UserProfileAnswer(Base):
__tablename__ = "user_profile_answers"
__table_args__ = (
UniqueConstraint("user_id", "question_id", name="uq_user_profile_answer"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True)
value_text = Column(Text, nullable=True)
updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers")
question = relationship("ProfileQuestion", back_populates="answers")
updated_by_user = relationship("User", foreign_keys=[updated_by_user_id])
class MembershipTier(Base):
+14
View File
@@ -37,6 +37,13 @@ from .schemas import (
EventRSVPBase,
EventRSVPUpdate,
EventRSVPResponse,
QuestionOption,
ProfileQuestionCreate,
ProfileQuestionUpdate,
ProfileQuestionResponse,
ProfileQuestionForUser,
ProfileAnswerUpdate,
ProfileAnswersUpdateRequest,
)
__all__ = [
@@ -78,4 +85,11 @@ __all__ = [
"EventRSVPBase",
"EventRSVPUpdate",
"EventRSVPResponse",
"QuestionOption",
"ProfileQuestionCreate",
"ProfileQuestionUpdate",
"ProfileQuestionResponse",
"ProfileQuestionForUser",
"ProfileAnswerUpdate",
"ProfileAnswersUpdateRequest",
]
+80 -1
View File
@@ -1,5 +1,5 @@
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from typing import Optional
from typing import Optional, Literal, Any
from datetime import datetime, date
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
@@ -24,6 +24,7 @@ class UserUpdate(BaseModel):
phone: Optional[str] = None
address: Optional[str] = None
role: Optional[UserRole] = None
volunteer_level: Optional[str] = Field(None, max_length=50)
class UserResponse(UserBase):
@@ -31,6 +32,7 @@ class UserResponse(UserBase):
id: int
role: UserRole
volunteer_level: Optional[str] = None
is_active: bool
created_at: datetime
last_login: Optional[datetime] = None
@@ -285,3 +287,80 @@ class EventRSVPResponse(EventRSVPBase):
attended: bool
created_at: datetime
updated_at: datetime
# Profile Question Schemas
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
class QuestionOption(BaseModel):
label: str = Field(..., min_length=1, max_length=100)
value: str = Field(..., min_length=1, max_length=100)
class ProfileQuestionBase(BaseModel):
key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
label: str = Field(..., min_length=2, max_length=255)
help_text: Optional[str] = None
input_type: ProfileQuestionInputType
placeholder: Optional[str] = Field(None, max_length=255)
options: Optional[list[QuestionOption]] = None
is_required: bool = False
is_active: bool = True
admin_only_edit: bool = False
display_order: int = 0
depends_on_question_id: Optional[int] = None
depends_on_value: Optional[str] = Field(None, max_length=255)
class ProfileQuestionCreate(ProfileQuestionBase):
pass
class ProfileQuestionUpdate(BaseModel):
key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
label: Optional[str] = Field(None, min_length=2, max_length=255)
help_text: Optional[str] = None
input_type: Optional[ProfileQuestionInputType] = None
placeholder: Optional[str] = Field(None, max_length=255)
options: Optional[list[QuestionOption]] = None
is_required: Optional[bool] = None
is_active: Optional[bool] = None
admin_only_edit: Optional[bool] = None
display_order: Optional[int] = None
depends_on_question_id: Optional[int] = None
depends_on_value: Optional[str] = Field(None, max_length=255)
class ProfileQuestionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
key: str
label: str
help_text: Optional[str] = None
input_type: ProfileQuestionInputType
placeholder: Optional[str] = None
options: list[QuestionOption] = []
is_required: bool
is_active: bool
admin_only_edit: bool
display_order: int
depends_on_question_id: Optional[int] = None
depends_on_value: Optional[str] = None
created_at: datetime
updated_at: datetime
class ProfileQuestionForUser(ProfileQuestionResponse):
answer: Optional[Any] = None
can_edit: bool = True
class ProfileAnswerUpdate(BaseModel):
question_id: int
value: Optional[Any] = None
class ProfileAnswersUpdateRequest(BaseModel):
answers: list[ProfileAnswerUpdate]
+7
View File
@@ -0,0 +1,7 @@
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[2]
if str(APP_ROOT) not in sys.path:
sys.path.insert(0, str(APP_ROOT))
@@ -0,0 +1,113 @@
from datetime import date, datetime
import pytest
from fastapi import HTTPException
from app.api.v1.users import (
_deserialize_answer_value,
_normalize_answer_value,
_normalize_volunteer_level,
_parse_options,
_serialize_options,
)
from app.models.models import ProfileQuestion
from app.schemas import QuestionOption
def make_question(input_type: str, options_json: str | None = None) -> ProfileQuestion:
return ProfileQuestion(
key=f"{input_type}_question",
label=f"{input_type.title()} Question",
input_type=input_type,
options_json=options_json,
)
def test_option_parsing_and_serialization_filters_invalid_items() -> None:
assert _parse_options('[{"label":" Yes ","value":" yes "}, {"label":"","value":"no"}, "bad"]') == [
{"label": "Yes", "value": "yes"}
]
assert _parse_options("not-json") == []
serialized = _serialize_options([QuestionOption(label="Private Pilot", value="ppl")])
assert _parse_options(serialized) == [{"label": "Private Pilot", "value": "ppl"}]
@pytest.mark.parametrize(
("value", "expected"),
[
(True, "true"),
("yes", "true"),
("0", "false"),
(False, "false"),
],
)
def test_boolean_answers_are_normalized(value: object, expected: str) -> None:
assert _normalize_answer_value(make_question("boolean"), value) == expected
def test_invalid_boolean_answer_raises_400() -> None:
with pytest.raises(HTTPException) as exc:
_normalize_answer_value(make_question("boolean"), "maybe")
assert exc.value.status_code == 400
@pytest.mark.parametrize(
("value", "expected"),
[
(3, "3"),
("3.50", "3.5"),
(date(2026, 5, 4), "2026-05-04"),
(datetime(2026, 5, 4, 12, 30), "2026-05-04"),
],
)
def test_number_and_date_answers_are_normalized(value: object, expected: str) -> None:
input_type = "date" if isinstance(value, (date, datetime)) else "number"
assert _normalize_answer_value(make_question(input_type), value) == expected
def test_select_answers_must_match_configured_options() -> None:
question = make_question("select", '[{"label":"Private Pilot","value":"ppl"}]')
assert _normalize_answer_value(question, "ppl") == "ppl"
with pytest.raises(HTTPException) as exc:
_normalize_answer_value(question, "cpl")
assert exc.value.status_code == 400
def test_empty_answers_clear_existing_values() -> None:
assert _normalize_answer_value(make_question("text"), "") is None
assert _normalize_answer_value(make_question("text"), None) is None
def test_answer_deserialization_restores_frontend_types() -> None:
assert _deserialize_answer_value(make_question("boolean"), "true") is True
assert _deserialize_answer_value(make_question("boolean"), "false") is False
assert _deserialize_answer_value(make_question("number"), "10") == 10
assert _deserialize_answer_value(make_question("number"), "10.5") == 10.5
assert _deserialize_answer_value(make_question("text"), "SASA") == "SASA"
@pytest.mark.parametrize(
("value", "expected"),
[
("yes", "yes"),
("true", "yes"),
("0", "no"),
("", None),
(None, None),
],
)
def test_volunteer_level_accepts_boolean_like_values(value: str | None, expected: str | None) -> None:
assert _normalize_volunteer_level(value) == expected
def test_invalid_volunteer_level_raises_400() -> None:
with pytest.raises(HTTPException) as exc:
_normalize_volunteer_level("sometimes")
assert exc.value.status_code == 400
+3
View File
@@ -28,3 +28,6 @@ email-validator==2.1.0
aiofiles==23.2.1
Jinja2==3.1.2
python-dateutil==2.8.2
# Tests
pytest==8.3.4
+4 -2
View File
@@ -17,10 +17,12 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.5"
"vite": "^5.0.5",
"vitest": "^1.6.1"
}
}
+779 -114
View File
File diff suppressed because it is too large Load Diff
+39 -6
View File
@@ -6,15 +6,27 @@ import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
import Dashboard from './pages/Dashboard';
import EmailTemplates from './pages/EmailTemplates';
import MembershipTiers from './pages/MembershipTiers';
import BounceManagement from './pages/BounceManagement';
import PrivacyPolicy from './pages/PrivacyPolicy';
import TermsOfService from './pages/TermsOfService';
import './App.css';
import { useState } from 'react';
import { Link } from 'react-router-dom';
const App: React.FC = () => {
const [cookieDismissed, setCookieDismissed] = useState(
() => localStorage.getItem('cookie_notice_dismissed') === 'true'
);
const dismissCookies = () => {
localStorage.setItem('cookie_notice_dismissed', 'true');
setCookieDismissed(true);
};
return (
<FeatureFlagProvider>
<BrowserRouter>
<div className="app-shell">
<main className="app-main">
<Routes>
<Route path="/" element={<Navigate to="/login" />} />
<Route path="/register" element={<Register />} />
@@ -22,10 +34,31 @@ const App: React.FC = () => {
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/email-templates" element={<EmailTemplates />} />
<Route path="/membership-tiers" element={<MembershipTiers />} />
<Route path="/bounce-management" element={<BounceManagement />} />
<Route path="/email-templates" element={<Navigate to="/dashboard" />} />
<Route path="/membership-tiers" element={<Navigate to="/dashboard" />} />
<Route path="/bounce-management" element={<Navigate to="/dashboard" />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
<Route path="/terms-of-service" element={<TermsOfService />} />
</Routes>
</main>
<footer className="site-footer">
<div>
<Link to="/privacy-policy">Privacy Policy</Link>
<Link to="/terms-of-service">Terms of Service</Link>
</div>
<div style={{ marginTop: '8px' }}>SASA Portal</div>
</footer>
{!cookieDismissed && (
<div className="cookie-banner">
<div>
We use cookies for session authentication, security, and basic site functionality.
</div>
<button className="btn btn-primary" style={{ padding: '6px 12px' }} onClick={dismissCookies}>
OK
</button>
</div>
)}
</div>
</BrowserRouter>
</FeatureFlagProvider>
);
@@ -0,0 +1,410 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
ProfileQuestion,
ProfileQuestionInputType,
ProfileQuestionOption,
ProfileQuestionUpsertData,
userService
} from '../services/membershipService';
interface AdminProfileQuestionManagerProps {
onQuestionsChanged?: () => void;
}
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
if (!options || options.length === 0) {
return '';
}
return options.map((option) => `${option.label}|${option.value}`).join('\n');
};
const textToOptions = (value: string): ProfileQuestionOption[] => {
return value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [labelPart, valuePart] = line.split('|');
const label = (labelPart || '').trim();
const optionValue = (valuePart || labelPart || '').trim();
return { label, value: optionValue };
})
.filter((option) => option.label.length > 0 && option.value.length > 0);
};
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({ onQuestionsChanged }) => {
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
const [listSearch, setListSearch] = useState('');
const emptyForm: ProfileQuestionUpsertData = {
key: '',
label: '',
help_text: '',
input_type: 'text',
placeholder: '',
options: null,
is_required: false,
is_active: true,
admin_only_edit: false,
display_order: 0,
depends_on_question_id: null,
depends_on_value: null
};
const [formData, setFormData] = useState<ProfileQuestionUpsertData>(emptyForm);
const [optionsText, setOptionsText] = useState('');
const loadQuestions = async () => {
try {
setLoading(true);
const data = await userService.getAdminProfileQuestions(true);
setQuestions(data);
setError(null);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load questions');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadQuestions();
}, []);
const dependencyCandidates = useMemo(() => {
return questions.filter((question) => question.id !== editingQuestionId);
}, [questions, editingQuestionId]);
const selectedDependencyQuestion = useMemo(() => {
if (!formData.depends_on_question_id) {
return null;
}
return questions.find((question) => question.id === formData.depends_on_question_id) || null;
}, [questions, formData.depends_on_question_id]);
const filteredQuestions = useMemo(() => {
const term = listSearch.trim().toLowerCase();
if (!term) {
return questions;
}
return questions.filter((question) =>
question.label.toLowerCase().includes(term) ||
question.key.toLowerCase().includes(term)
);
}, [questions, listSearch]);
const resetForm = () => {
setFormData(emptyForm);
setOptionsText('');
setEditingQuestionId(null);
};
const handleEdit = (question: ProfileQuestion) => {
setEditingQuestionId(question.id);
setFormData({
key: question.key,
label: question.label,
help_text: question.help_text,
input_type: question.input_type,
placeholder: question.placeholder,
options: question.options,
is_required: question.is_required,
is_active: question.is_active,
admin_only_edit: question.admin_only_edit,
display_order: question.display_order,
depends_on_question_id: question.depends_on_question_id,
depends_on_value: question.depends_on_value
});
setOptionsText(optionsToText(question.options));
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const payload: ProfileQuestionUpsertData = {
...formData,
key: formData.key.trim(),
label: formData.label.trim(),
help_text: formData.help_text?.trim() || null,
placeholder: formData.placeholder?.trim() || null,
depends_on_value: formData.depends_on_value?.trim() || null,
options: formData.input_type === 'select' ? textToOptions(optionsText) : null,
};
if (editingQuestionId) {
await userService.updateAdminProfileQuestion(editingQuestionId, payload);
} else {
await userService.createAdminProfileQuestion(payload);
}
await loadQuestions();
resetForm();
onQuestionsChanged?.();
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save question');
} finally {
setSaving(false);
}
};
const handleDeactivate = async (questionId: number) => {
if (!window.confirm('Deactivate this question? Existing answers are kept.')) {
return;
}
try {
await userService.deactivateAdminProfileQuestion(questionId);
await loadQuestions();
onQuestionsChanged?.();
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to deactivate question');
}
};
return (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '10px' }}>Profile Questions (Admin)</h3>
<p style={{ marginBottom: '16px', color: '#555' }}>
Manage the set of profile questions users can answer. You can add follow-up questions with dependencies.
</p>
{error && <div className="alert alert-error">{error}</div>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '20px' }}>
<input
type="text"
placeholder="Question key (e.g. pilot_license_type)"
value={formData.key}
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Question label"
value={formData.label}
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<textarea
placeholder="Help text (optional)"
value={formData.help_text || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
rows={2}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
<select
value={formData.input_type}
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
{INPUT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<input
type="number"
placeholder="Display order"
value={formData.display_order ?? 0}
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Placeholder"
value={formData.placeholder || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
<select
value={formData.depends_on_question_id ?? ''}
onChange={(event) => {
const nextValue = event.target.value;
setFormData((prev) => ({
...prev,
depends_on_question_id: nextValue ? Number(nextValue) : null,
depends_on_value: null
}));
}}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">No dependency</option>
{dependencyCandidates.map((question) => (
<option key={question.id} value={question.id}>{question.label}</option>
))}
</select>
{!selectedDependencyQuestion && (
<input
type="text"
placeholder="Choose a dependency question first"
value=""
disabled
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f7fa' }}
/>
)}
{selectedDependencyQuestion?.input_type === 'select' && (
<select
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">Any answered value</option>
{selectedDependencyQuestion.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{selectedDependencyQuestion?.input_type === 'boolean' && (
<select
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">Any answered value</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
)}
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
<input
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
placeholder="Show when parent answer equals..."
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
)}
</div>
{formData.input_type === 'select' && (
<textarea
value={optionsText}
onChange={(event) => setOptionsText(event.target.value)}
rows={4}
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
)}
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<label>
<input
type="checkbox"
checked={Boolean(formData.is_required)}
onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Required
</label>
<label>
<input
type="checkbox"
checked={Boolean(formData.admin_only_edit)}
onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Admin-only edits
</label>
<label>
<input
type="checkbox"
checked={Boolean(formData.is_active)}
onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
</button>
{editingQuestionId && (
<button className="btn btn-secondary" onClick={resetForm}>
Cancel Edit
</button>
)}
</div>
</div>
<h4 style={{ marginBottom: '10px' }}>Existing Questions</h4>
<input
type="text"
placeholder="Search by label or key..."
value={listSearch}
onChange={(event) => setListSearch(event.target.value)}
style={{ marginBottom: '10px', width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
{loading ? (
<p>Loading questions...</p>
) : (
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px', textAlign: 'left' }}>Order</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Label</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Type</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Key</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredQuestions.map((question) => (
<tr key={question.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px' }}>{question.display_order}</td>
<td style={{ padding: '8px' }}>
{question.label}
{question.admin_only_edit && (
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
</td>
<td style={{ padding: '8px' }}>{question.input_type}</td>
<td style={{ padding: '8px' }}>{question.key}</td>
<td style={{ padding: '8px' }}>{question.is_active ? 'Active' : 'Inactive'}</td>
<td style={{ padding: '8px' }}>
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 8px', marginRight: '6px' }} onClick={() => handleEdit(question)}>
Edit
</button>
{question.is_active && (
<button className="btn btn-danger" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => handleDeactivate(question.id)}>
Deactivate
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredQuestions.length === 0 && (
<p style={{ padding: '10px', color: '#666' }}>No questions match your search.</p>
)}
</div>
)}
</div>
);
};
export default AdminProfileQuestionManager;
@@ -159,10 +159,14 @@ const EmailTemplateManagement: React.FC = () => {
overflow: 'auto',
fontSize: '13px',
lineHeight: '1.4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: '#333'
}}
dangerouslySetInnerHTML={{ __html: template.html_body.substring(0, 300) + '...' }}
/>
>
{template.html_body.substring(0, 300)}
{template.html_body.length > 300 ? '...' : ''}
</div>
</div>
</div>
))}
+3 -34
View File
@@ -9,7 +9,7 @@ interface ProfileMenuProps {
onEditProfile?: () => void;
}
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => {
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile }) => {
const [isOpen, setIsOpen] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -135,42 +135,11 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onE
)}
{/* Menu Items */}
{userRole === 'super_admin' && (
<>
<button
style={{ ...menuItemStyle, borderRadius: user ? '0' : '4px 4px 0 0' }}
onClick={() => {
navigate('/membership-tiers');
setIsOpen(false);
}}
>
Membership Tiers
</button>
<button
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
onClick={() => {
navigate('/email-templates');
setIsOpen(false);
}}
>
Email Templates
</button>
<button
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
onClick={() => {
navigate('/bounce-management');
setIsOpen(false);
}}
>
Bounce Management
</button>
</>
)}
<button
style={{
...menuItemStyle,
borderRadius: '0',
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
borderRadius: user ? '0' : '4px 4px 0 0',
borderTop: user ? '1px solid #eee' : 'none'
}}
onClick={handleChangePassword}
>
@@ -0,0 +1,310 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ProfileAnswerInput, ProfileQuestionForUser } from '../services/membershipService';
import {
answerToComparable,
canEditProfileQuestion,
isProfileQuestionVisible,
ProfileQuestionAnswerValue
} from '../utils/profileQuestionLogic';
interface ProfileQuestionsFormProps {
title: string;
description?: string;
questions: ProfileQuestionForUser[];
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
saveLabel?: string;
allowAdminManagedEdit?: boolean;
}
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
if (value === null || value === undefined || value === '') {
return 'Not set';
}
if (question.input_type === 'boolean') {
return value === true || value === 'true' ? 'Yes' : 'No';
}
if (question.input_type === 'select') {
const matchingOption = question.options.find((option) => option.value === String(value));
return matchingOption?.label || String(value);
}
return String(value);
};
const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
title,
description,
questions,
onSave,
saveLabel = 'Save Answers',
allowAdminManagedEdit = false
}) => {
const initialAnswers = useMemo(() => {
const values: Record<number, ProfileQuestionAnswerValue> = {};
questions.forEach((question) => {
values[question.id] = question.answer ?? null;
});
return values;
}, [questions]);
const [answers, setAnswers] = useState<Record<number, ProfileQuestionAnswerValue>>(initialAnswers);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
setAnswers(initialAnswers);
setSuccessMessage(null);
setError(null);
}, [initialAnswers]);
const visibleQuestions = useMemo(() => {
const byId = new Map<number, ProfileQuestionForUser>();
questions.forEach((question) => byId.set(question.id, question));
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
}, [questions, answers]);
const filteredQuestions = useMemo(() => {
const searchTerm = search.trim().toLowerCase();
return visibleQuestions
.filter((question) => {
if (!searchTerm) {
return true;
}
return (
question.label.toLowerCase().includes(searchTerm) ||
question.key.toLowerCase().includes(searchTerm) ||
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
}, [visibleQuestions, search]);
const paginatedQuestions = useMemo(() => {
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page]);
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
setAnswers((prev) => ({
...prev,
[questionId]: value
}));
setSuccessMessage(null);
setError(null);
};
useEffect(() => {
setPage(1);
}, [search]);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
const changedAnswers: ProfileAnswerInput[] = questions
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
.filter((question) => {
const current = answerToComparable(answers[question.id] ?? null);
const initial = answerToComparable(initialAnswers[question.id] ?? null);
return current !== initial;
})
.map((question) => ({
question_id: question.id,
value: answers[question.id] ?? null
}));
await onSave(changedAnswers);
setSuccessMessage(changedAnswers.length > 0 ? 'Saved successfully.' : 'No changes to save.');
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save answers.');
} finally {
setSaving(false);
}
};
const renderField = (question: ProfileQuestionForUser) => {
const value = answers[question.id] ?? null;
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
if (disabled && !saving) {
return (
<div className="profile-question-readonly">
{formatAnswerForDisplay(question, value)}
</div>
);
}
if (question.input_type === 'boolean') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === '') {
setAnswerValue(question.id, null);
} else {
setAnswerValue(question.id, nextValue === 'true');
}
}}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">Prefer not to say</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (question.input_type === 'select') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">Select an option</option>
{question.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
if (question.input_type === 'date') {
return (
<input
type="date"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
}
if (question.input_type === 'number') {
return (
<input
type="number"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
}
return (
<input
type="text"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
};
return (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '8px' }}>{title}</h3>
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}>
<input
type="text"
placeholder="Search questions..."
value={search}
onChange={(event) => setSearch(event.target.value)}
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{successMessage && (
<div className="alert alert-success">
{successMessage}
</div>
)}
{filteredQuestions.length === 0 ? (
<p style={{ color: '#666' }}>No questions available.</p>
) : (
<div style={{ display: 'grid', gap: '16px' }}>
{paginatedQuestions.map((question) => (
<div key={question.id} className="profile-question-row">
<div className="profile-question-meta">
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}>
{question.label}
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>}
{question.admin_only_edit && (
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
</label>
{question.help_text && (
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p>
)}
</div>
<div className="profile-question-answer">{renderField(question)}</div>
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}>
This field can only be changed by an admin.
</p>
)}
</div>
))}
</div>
)}
{filteredQuestions.length > pageSize && (
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
<span style={{ fontSize: '13px', color: '#525a66' }}>
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
Previous
</button>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
Next
</button>
</div>
</div>
)}
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : saveLabel}
</button>
</div>
</div>
);
};
export default ProfileQuestionsForm;
+1 -1
View File
@@ -163,7 +163,7 @@ interface TierManagementProps {
onCancelEdit: () => void;
}
const TierManagement: React.FC<TierManagementProps> = ({
export const TierManagement: React.FC<TierManagementProps> = ({
tiers,
loading,
showCreateForm,
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
const PrivacyPolicy: React.FC = () => {
return (
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
<div className="card">
<h2 style={{ marginBottom: '12px' }}>Privacy Policy</h2>
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
Privacy policy content will be added here.
</p>
</div>
</div>
);
};
export default PrivacyPolicy;
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
const TermsOfService: React.FC = () => {
return (
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
<div className="card">
<h2 style={{ marginBottom: '12px' }}>Terms of Service</h2>
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
Terms of service content will be added here.
</p>
</div>
</div>
);
};
export default TermsOfService;
@@ -26,11 +26,62 @@ export interface User {
phone: string | null;
address: string | null;
role: string;
volunteer_level: string | null;
is_active: boolean;
created_at: string;
last_login: string | null;
}
export type ProfileQuestionInputType = 'text' | 'number' | 'boolean' | 'date' | 'select';
export interface ProfileQuestionOption {
label: string;
value: string;
}
export interface ProfileQuestion {
id: number;
key: string;
label: string;
help_text: string | null;
input_type: ProfileQuestionInputType;
placeholder: string | null;
options: ProfileQuestionOption[];
is_required: boolean;
is_active: boolean;
admin_only_edit: boolean;
display_order: number;
depends_on_question_id: number | null;
depends_on_value: string | null;
created_at: string;
updated_at: string;
}
export interface ProfileQuestionForUser extends ProfileQuestion {
answer: string | number | boolean | null;
can_edit: boolean;
}
export interface ProfileQuestionUpsertData {
key: string;
label: string;
help_text?: string | null;
input_type: ProfileQuestionInputType;
placeholder?: string | null;
options?: ProfileQuestionOption[] | null;
is_required?: boolean;
is_active?: boolean;
admin_only_edit?: boolean;
display_order?: number;
depends_on_question_id?: number | null;
depends_on_value?: string | null;
}
export interface ProfileAnswerInput {
question_id: number;
value: string | number | boolean | null;
}
export interface MembershipTier {
id: number;
name: string;
@@ -230,6 +281,51 @@ export const userService = {
const response = await api.delete(`/users/${userId}`);
return response.data;
},
async getMyProfileQuestions(): Promise<ProfileQuestionForUser[]> {
const response = await api.get('/users/me/profile-questions');
return response.data;
},
async updateMyProfileAnswers(answers: ProfileAnswerInput[]): Promise<{ message: string }> {
const response = await api.put('/users/me/profile-answers', { answers });
return response.data;
},
async getAdminProfileQuestions(includeInactive: boolean = true): Promise<ProfileQuestion[]> {
const response = await api.get(`/users/admin/profile-questions?include_inactive=${includeInactive}`);
return response.data;
},
async createAdminProfileQuestion(data: ProfileQuestionUpsertData): Promise<ProfileQuestion> {
const response = await api.post('/users/admin/profile-questions', data);
return response.data;
},
async updateAdminProfileQuestion(questionId: number, data: Partial<ProfileQuestionUpsertData>): Promise<ProfileQuestion> {
const response = await api.put(`/users/admin/profile-questions/${questionId}`, data);
return response.data;
},
async deactivateAdminProfileQuestion(questionId: number): Promise<{ message: string }> {
const response = await api.delete(`/users/admin/profile-questions/${questionId}`);
return response.data;
},
async getUserProfileAnswers(userId: number): Promise<ProfileQuestionForUser[]> {
const response = await api.get(`/users/admin/users/${userId}/profile-answers`);
return response.data;
},
async updateUserProfileAnswers(userId: number, answers: ProfileAnswerInput[]): Promise<{ message: string }> {
const response = await api.put(`/users/admin/users/${userId}/profile-answers`, { answers });
return response.data;
},
async sendUserPasswordReset(userId: number): Promise<{ message: string }> {
const response = await api.post(`/users/${userId}/send-password-reset`);
return response.data;
},
};
export const membershipService = {
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import {
canEditProfileQuestion,
DependentProfileQuestion,
isProfileQuestionVisible,
ProfileQuestionAnswerValue
} from './profileQuestionLogic';
describe('profile question logic', () => {
it('keeps admin-managed questions read-only outside admin editing mode', () => {
const question = {
id: 1,
admin_only_edit: true,
can_edit: true
};
expect(canEditProfileQuestion(question, false)).toBe(false);
expect(canEditProfileQuestion(question, true)).toBe(true);
});
it('does not allow editing when the API marks a question read-only', () => {
expect(canEditProfileQuestion({ id: 1, admin_only_edit: false, can_edit: false }, true)).toBe(false);
});
it('shows dependent questions when boolean answers match', () => {
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'true' };
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: true };
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(true);
});
it('hides dependent questions when select answers do not match', () => {
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'completed' };
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: 'pending' };
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(false);
});
});
@@ -0,0 +1,62 @@
export type ProfileQuestionAnswerValue = string | number | boolean | null;
export interface EditableProfileQuestion {
id: number;
admin_only_edit: boolean;
can_edit: boolean;
}
export interface DependentProfileQuestion {
id: number;
depends_on_question_id: number | null;
depends_on_value: string | null;
}
export const answerToComparable = (value: ProfileQuestionAnswerValue): string | null => {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return String(value);
};
export const canEditProfileQuestion = (
question: EditableProfileQuestion,
allowAdminManagedEdit = false
): boolean => {
if (!question.can_edit) {
return false;
}
if (question.admin_only_edit && !allowAdminManagedEdit) {
return false;
}
return true;
};
export const isProfileQuestionVisible = <TQuestion extends DependentProfileQuestion>(
question: TQuestion,
questionsById: Map<number, TQuestion>,
answers: Record<number, ProfileQuestionAnswerValue>
): boolean => {
if (!question.depends_on_question_id) {
return true;
}
const parentQuestion = questionsById.get(question.depends_on_question_id);
if (!parentQuestion) {
return true;
}
const parentAnswer = answerToComparable(answers[parentQuestion.id] ?? null);
if (question.depends_on_value === null || question.depends_on_value === undefined) {
return parentAnswer !== null && parentAnswer !== '';
}
return parentAnswer === question.depends_on_value;
};
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
docker compose build
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
docker compose down
docker compose up -d