forked from jamesp/sasa-membership
Add member profile questions, admin tooling, legal pages, and fast tests
- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
- Add admin UI for managing profile questions and member profile answers
- Add volunteer level/profile data support across backend schemas, models, API, and migration
- Update dashboard/profile UI, super admin menu, membership service types, and related styling
- Add privacy policy, terms of service, cookie notice, and footer links
- Add frontend Vitest coverage for profile question logic
- Add backend pytest coverage for profile answer normalization and validation
- Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
- Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
- Protect feature flag reload behind super-admin access
- Restrict admin-triggered password resets so admins can only reset member accounts
- Replace email template HTML preview rendering with escaped text preview
- Update docs for feature flag reload access, password reset scope, and email template preview safety
-- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
This commit is contained in:
+42
-12
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+39
-6
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
@@ -163,7 +163,7 @@ interface TierManagementProps {
|
||||
onCancelEdit: () => void;
|
||||
}
|
||||
|
||||
const TierManagement: React.FC<TierManagementProps> = ({
|
||||
export const TierManagement: React.FC<TierManagementProps> = ({
|
||||
tiers,
|
||||
loading,
|
||||
showCreateForm,
|
||||
|
||||
+675
-374
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user