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:
+43
-13
@@ -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.
|
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
|
## Core Features
|
||||||
|
|
||||||
### Public Member Features
|
### Public Member Features
|
||||||
- **Self-Service Registration**: Members can sign up online and select their membership tier
|
- **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
|
- **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
|
- **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
|
- **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
|
### Administrative Features
|
||||||
- **Member Database Management**: Query and modify member records
|
- **Member Database Management**: Query and modify member records
|
||||||
- **Manual Payment Entry**: Record cash payments to activate memberships
|
- **Manual Payment Entry**: Record cash payments to activate memberships
|
||||||
- **Membership Tier Management**: Configure different membership levels and associated fees
|
- **Membership Tier Management**: Configure different membership levels and associated fees
|
||||||
- **Meeting Management**: Post notices and updates about upcoming alliance meetings
|
- **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions
|
||||||
- **Reporting**: Generate reports on membership statistics and payment status
|
- **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance
|
||||||
- **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.
|
- **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
|
- **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
|
## Technical Stack
|
||||||
@@ -32,7 +41,8 @@ This project aims to develop a comprehensive membership management system for th
|
|||||||
- **Authentication**: JWT-based authentication system
|
- **Authentication**: JWT-based authentication system
|
||||||
- **Payment Integration**: Square API for payment processing
|
- **Payment Integration**: Square API for payment processing
|
||||||
- **Email Service**: SMTP2GO API for automated reminders and notifications
|
- **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
|
## Membership Tiers
|
||||||
|
|
||||||
@@ -73,21 +83,41 @@ Each tier will have associated annual fees and benefits.
|
|||||||
- `memberships`: Membership records with tier and status
|
- `memberships`: Membership records with tier and status
|
||||||
- `payments`: Payment transactions
|
- `payments`: Payment transactions
|
||||||
- `tiers`: Membership tier definitions
|
- `tiers`: Membership tier definitions
|
||||||
|
- `profile_questions`: Configurable profile/onboarding questions
|
||||||
|
- `user_profile_answers`: Per-member profile answers
|
||||||
- `events`: Event information and details
|
- `events`: Event information and details
|
||||||
- `event_rsvps`: Event registration and attendance tracking
|
- `event_rsvps`: Event registration and attendance tracking
|
||||||
- `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General)
|
- `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General)
|
||||||
- `volunteer_assignments`: Member-to-role assignments
|
- `volunteer_assignments`: Member-to-role assignments
|
||||||
- `volunteer_schedules`: Volunteer shift scheduling and availability
|
- `volunteer_schedules`: Volunteer shift scheduling and availability
|
||||||
- `certificates`: Training certificates and qualifications
|
- `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
|
- `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
|
## Development Phases
|
||||||
|
|
||||||
1. **Phase 1**: Core API development (authentication, user management)
|
1. **Phase 1**: Core API development (authentication, user management) - implemented
|
||||||
2. **Phase 2**: Payment integration and membership management
|
2. **Phase 2**: Payment integration and membership management - implemented
|
||||||
3. **Phase 3**: Admin interface development
|
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
|
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
|
5. **Phase 5**: Testing, deployment, and documentation - active; fast unit tests and documentation are in place
|
||||||
|
|
||||||
## Deployment Considerations
|
## Deployment Considerations
|
||||||
|
|
||||||
@@ -152,4 +182,4 @@ Each tier will have associated annual fees and benefits.
|
|||||||
- Payment processing success rate
|
- Payment processing success rate
|
||||||
- User engagement with portal
|
- User engagement with portal
|
||||||
- Administrative efficiency improvements
|
- Administrative efficiency improvements
|
||||||
- System uptime and performance
|
- System uptime and performance
|
||||||
|
|||||||
+110
-76
@@ -2,115 +2,149 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
membership/
|
membership/
|
||||||
├── .env # Environment configuration (ready to use)
|
├── .env # Local environment configuration
|
||||||
├── .env.example # Template for environment variables
|
├── .env.example # Environment variable template
|
||||||
├── .gitignore # Git ignore rules
|
├── .gitignore # Git ignore rules
|
||||||
├── docker-compose.yml # Docker services configuration
|
├── docker-compose.yml # Backend, frontend, gateway, and prod frontend services
|
||||||
├── INSTRUCTIONS.md # Original project requirements
|
├── restart.sh # Build, run fast tests, and restart the app
|
||||||
├── README.md # Complete documentation
|
├── INSTRUCTIONS.md # Product requirements and roadmap context
|
||||||
├── QUICKSTART.md # Quick start guide
|
├── README.md # Full project documentation
|
||||||
|
├── QUICKSTART.md # Short operator/developer guide
|
||||||
│
|
│
|
||||||
├── backend/ # FastAPI application
|
├── backend/ # FastAPI application
|
||||||
│ ├── Dockerfile # Backend container configuration
|
│ ├── Dockerfile
|
||||||
│ ├── requirements.txt # Python dependencies
|
│ ├── requirements.txt
|
||||||
|
│ ├── alembic.ini
|
||||||
|
│ ├── alembic/ # Database migrations
|
||||||
│ └── app/
|
│ └── app/
|
||||||
│ ├── __init__.py
|
│ ├── main.py # App, CORS, health check, router registration
|
||||||
│ ├── main.py # Application entry point
|
│ ├── api/
|
||||||
│ │
|
│ │ ├── dependencies.py # Auth dependencies
|
||||||
│ ├── api/ # API endpoints
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── dependencies.py # Auth dependencies
|
|
||||||
│ │ └── v1/
|
│ │ └── v1/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── auth.py # Register, login, password reset/change
|
||||||
│ │ ├── auth.py # Registration, login
|
│ │ ├── users.py # Users, profile questions, profile answers
|
||||||
│ │ ├── users.py # User management
|
│ │ ├── tiers.py # Membership tiers
|
||||||
│ │ ├── tiers.py # Membership tiers
|
│ │ ├── memberships.py
|
||||||
│ │ ├── memberships.py # Membership management
|
│ │ ├── payments.py # Manual, Square, refund, payment history
|
||||||
│ │ └── payments.py # Payment processing
|
│ │ ├── email.py # SMTP2GO email tests and bounce webhooks
|
||||||
│ │
|
│ │ ├── email_templates.py
|
||||||
│ ├── core/ # Core functionality
|
│ │ ├── events.py # Events and RSVPs
|
||||||
│ │ ├── __init__.py
|
│ │ └── feature_flags.py
|
||||||
│ │ ├── config.py # Configuration settings
|
│ ├── core/ # Config, database, security, default data
|
||||||
│ │ ├── database.py # Database connection
|
│ ├── models/ # SQLAlchemy models
|
||||||
│ │ └── security.py # Auth & password hashing
|
│ ├── schemas/ # Pydantic schemas
|
||||||
│ │
|
│ ├── services/ # Email, bounce, Square, feature flags
|
||||||
│ ├── models/ # Database models
|
│ └── tests/ # Fast backend pytest unit tests
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── models.py # SQLAlchemy models
|
|
||||||
│ │
|
|
||||||
│ ├── schemas/ # Pydantic schemas
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── schemas.py # Request/response schemas
|
|
||||||
│ │
|
|
||||||
│ ├── services/ # Business logic (placeholder)
|
|
||||||
│ └── utils/ # Utilities (placeholder)
|
|
||||||
│
|
│
|
||||||
├── database/ # Database initialization
|
├── docker/
|
||||||
│ └── init.sql # Default data & admin user
|
│ └── 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
|
## Key Files
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- **`.env`** - Environment variables (database, API keys, etc.)
|
- **`.env`** - Runtime configuration for database, auth, Square, SMTP2GO, ports, and gateway TLS.
|
||||||
- **`docker-compose.yml`** - Services: MySQL + FastAPI backend
|
- **`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 Application
|
||||||
- **`backend/app/main.py`** - FastAPI app initialization, CORS, routes
|
- **`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/config.py`** - Settings management.
|
||||||
- **`backend/app/core/security.py`** - JWT tokens, password hashing
|
- **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions.
|
||||||
- **`backend/app/models/models.py`** - Database tables (User, Membership, Payment, etc.)
|
- **`backend/app/core/security.py`** - JWT tokens and password hashing.
|
||||||
- **`backend/app/schemas/schemas.py`** - API request/response models
|
- **`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)
|
### Frontend Application
|
||||||
- **`auth.py`** - Register, login
|
- **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard.
|
||||||
- **`users.py`** - User profile, admin user management
|
- **`frontend/src/components/MembershipSetup.tsx`** - Membership tier selection and payment flow.
|
||||||
- **`tiers.py`** - Membership tier CRUD
|
- **`frontend/src/components/SquarePayment.tsx`** - Square Web Payments SDK form.
|
||||||
- **`memberships.py`** - Membership management
|
- **`frontend/src/components/AdminProfileQuestionManager.tsx`** - Admin profile-question configuration.
|
||||||
- **`payments.py`** - Payment processing & history
|
- **`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
|
## Database Models
|
||||||
|
|
||||||
Fully implemented:
|
Fully implemented:
|
||||||
- **User** - Authentication, profile, roles (member/admin/super_admin)
|
- **User** - Authentication, profile, roles, volunteer level.
|
||||||
- **MembershipTier** - Configurable tiers with fees and benefits
|
- **ProfileQuestion** - Configurable profile fields, options, dependencies, admin-only edit flags.
|
||||||
- **Membership** - User memberships with status tracking
|
- **UserProfileAnswer** - Per-user answers with update attribution.
|
||||||
- **Payment** - Payment records with multiple methods
|
- **MembershipTier** - Configurable tiers with fees and benefits.
|
||||||
- **Event** - Event management (model ready, endpoints TODO)
|
- **Membership** - User memberships with status, dates, and auto-renew flag.
|
||||||
- **EventRSVP** - Event registration (model ready, endpoints TODO)
|
- **Payment** - Payment records for Square, cash, check, and dummy methods.
|
||||||
- **VolunteerRole** - Volunteer roles (model ready, endpoints TODO)
|
- **Event** - Event management records.
|
||||||
- **VolunteerAssignment** - Role assignments (model ready, endpoints TODO)
|
- **EventRSVP** - RSVP and attendance records.
|
||||||
- **VolunteerSchedule** - Shift scheduling (model ready, endpoints TODO)
|
- **EmailTemplate** - Editable database-backed email templates.
|
||||||
- **Certificate** - Training certificates (model ready, endpoints TODO)
|
- **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking.
|
||||||
- **File** - File repository (model ready, endpoints TODO)
|
- **PasswordResetToken** - One-time password reset support.
|
||||||
- **Notification** - Email tracking (model ready, endpoints TODO)
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start everything
|
# Start everything
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
docker-compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
# Access API docs
|
# 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
|
## Default Credentials
|
||||||
|
|
||||||
**Admin**: admin@swanseaairport.org / admin123
|
**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
|
1. Expand authenticated API tests for member/admin workflows
|
||||||
2. Add Square payment integration
|
2. Add member file repository endpoints and UI
|
||||||
3. Implement email notifications
|
3. Build richer volunteer assignment, schedule, and certificate screens
|
||||||
4. Create event management endpoints
|
4. Add renewal reminder batch jobs
|
||||||
5. Add volunteer management endpoints
|
5. Add reporting and analytics
|
||||||
6. Build frontend interface
|
|
||||||
|
|||||||
+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`.
|
Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`.
|
||||||
TLS certs are auto-generated by the gateway container on first start.
|
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
|
## Testing the API
|
||||||
|
|
||||||
### 1. Register a new user
|
### 1. Register a new user
|
||||||
@@ -108,6 +122,26 @@ docker compose logs -f gateway
|
|||||||
1. Login as admin
|
1. Login as admin
|
||||||
2. GET `/api/v1/users/`
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
### Check service status
|
### Check service status
|
||||||
@@ -143,4 +177,6 @@ docker compose up -d
|
|||||||
3. Create additional admin users
|
3. Create additional admin users
|
||||||
4. Configure membership tiers as needed
|
4. Configure membership tiers as needed
|
||||||
5. Test payment processing
|
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
|
# 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
|
## Features
|
||||||
|
|
||||||
- **User Management**: Registration, authentication, and profile management
|
- **Authentication and accounts**: Registration, JSON/form login, JWT sessions, password reset, password change, and role-based access for members, admins, and super admins.
|
||||||
- **Membership Tiers**: Configurable membership levels with different benefits and fees
|
- **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.
|
||||||
- **Payment Processing**: Support for Square payments, cash, and check payments
|
- **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.
|
||||||
- **Admin Dashboard**: Complete administrative control over members and payments
|
- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits.
|
||||||
- **Event Management**: Create and manage events with RSVP tracking (coming soon)
|
- **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.
|
||||||
- **Volunteer Management**: Role assignments, scheduling, and certificates (coming soon)
|
- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility.
|
||||||
- **Email Notifications**: Automated notifications via SMTP2GO (coming soon)
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: FastAPI (Python 3.11)
|
- **Backend**: FastAPI (Python 3.11)
|
||||||
|
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS
|
||||||
- **Database**: MySQL 8.0
|
- **Database**: MySQL 8.0
|
||||||
- **Authentication**: JWT tokens with OAuth2
|
- **Authentication**: JWT tokens with OAuth2
|
||||||
- **Containerization**: Docker & Docker Compose
|
- **Containerization**: Docker & Docker Compose
|
||||||
- **ORM**: SQLAlchemy
|
- **ORM**: SQLAlchemy
|
||||||
|
- **Migrations**: Alembic
|
||||||
|
- **Payments**: Square Web Payments SDK and Square API
|
||||||
|
- **Email**: SMTP2GO
|
||||||
|
- **Tests**: Vitest and pytest
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -37,7 +45,11 @@ membership/
|
|||||||
│ │ │ │ ├── users.py # User management
|
│ │ │ │ ├── users.py # User management
|
||||||
│ │ │ │ ├── tiers.py # Membership tiers
|
│ │ │ │ ├── tiers.py # Membership tiers
|
||||||
│ │ │ │ ├── memberships.py # Membership management
|
│ │ │ │ ├── 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
|
│ │ │ └── dependencies.py # Auth dependencies
|
||||||
│ │ ├── core/
|
│ │ ├── core/
|
||||||
│ │ │ ├── config.py # Configuration
|
│ │ │ ├── config.py # Configuration
|
||||||
@@ -50,8 +62,13 @@ membership/
|
|||||||
│ │ └── main.py # Application entry point
|
│ │ └── main.py # Application entry point
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── database/
|
├── frontend/
|
||||||
│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
|
│ ├── 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
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -95,6 +112,25 @@ membership/
|
|||||||
- API Documentation: http://localhost:8050/docs
|
- API Documentation: http://localhost:8050/docs
|
||||||
- TLS certs are generated automatically by the gateway container on first start
|
- 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
|
## Frontend Development vs Production
|
||||||
|
|
||||||
### Development Mode (Vite)
|
### Development Mode (Vite)
|
||||||
@@ -191,6 +227,39 @@ docker compose --profile prod down
|
|||||||
- `PUT /api/v1/payments/{id}` - Update payment (admin)
|
- `PUT /api/v1/payments/{id}` - Update payment (admin)
|
||||||
- `GET /api/v1/payments/` - List all payments (admin)
|
- `GET /api/v1/payments/` - List all payments (admin)
|
||||||
- `POST /api/v1/payments/manual-payment` - Record manual payment (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
|
## Docker Compose Commands
|
||||||
|
|
||||||
@@ -362,16 +431,13 @@ docker compose up -d
|
|||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Remaining Roadmap
|
||||||
|
|
||||||
- [ ] Implement Square payment integration
|
- [ ] Add member file upload/repository endpoints and UI
|
||||||
- [ ] Add email notification system
|
- [ ] Add richer volunteer role, assignment, schedule, and certificate screens on top of the existing models
|
||||||
- [ ] Create event management endpoints
|
- [ ] Implement automated renewal reminder batch jobs
|
||||||
- [ ] Add volunteer management features
|
|
||||||
- [ ] Build frontend interface
|
|
||||||
- [ ] Add file upload/management
|
|
||||||
- [ ] Implement automated renewal reminders
|
|
||||||
- [ ] Add reporting and analytics
|
- [ ] Add reporting and analytics
|
||||||
|
- [ ] Expand test coverage around authenticated API flows and payment/email service boundaries
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+15
-13
@@ -36,7 +36,7 @@
|
|||||||
- [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide
|
- [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide
|
||||||
- [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details
|
- [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details
|
||||||
- [x] Created `SQUARE_QUICKSTART.md` - Quick start guide
|
- [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
|
### Code Quality
|
||||||
- [x] No Python syntax errors
|
- [x] No Python syntax errors
|
||||||
@@ -64,13 +64,15 @@ Before deploying, complete these steps:
|
|||||||
- [ ] Set SQUARE_ENVIRONMENT=sandbox
|
- [ ] Set SQUARE_ENVIRONMENT=sandbox
|
||||||
|
|
||||||
### 3. Deployment
|
### 3. Deployment
|
||||||
- [ ] Run `./deploy-square.sh` OR
|
- [ ] Run `./restart.sh` OR
|
||||||
- [ ] Run `docker-compose down`
|
- [ ] Run `docker compose build`
|
||||||
- [ ] Run `docker-compose up -d --build`
|
- [ ] Run `docker compose run --rm frontend npm test`
|
||||||
- [ ] Verify containers are running: `docker-compose ps`
|
- [ ] Run `docker compose run --rm backend pytest -q`
|
||||||
|
- [ ] Run `docker compose up -d`
|
||||||
|
- [ ] Verify containers are running: `docker compose ps`
|
||||||
|
|
||||||
### 4. Testing
|
### 4. Testing
|
||||||
- [ ] Access frontend at http://localhost:3000
|
- [ ] Access frontend at http://localhost:8050 or HTTPS at https://localhost:8443
|
||||||
- [ ] Login/register a user
|
- [ ] Login/register a user
|
||||||
- [ ] Navigate to membership setup
|
- [ ] Navigate to membership setup
|
||||||
- [ ] Select a membership tier
|
- [ ] Select a membership tier
|
||||||
@@ -104,7 +106,7 @@ After deployment, run these commands to verify:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check backend is running
|
# 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):
|
# Expected output (with your actual IDs):
|
||||||
# {
|
# {
|
||||||
@@ -114,10 +116,10 @@ curl http://localhost:8000/api/v1/payments/config/square
|
|||||||
# }
|
# }
|
||||||
|
|
||||||
# Check frontend is running
|
# Check frontend is running
|
||||||
curl http://localhost:3000
|
curl http://localhost:8050
|
||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
docker-compose logs backend | grep -i square
|
docker compose logs backend | grep -i square
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 Testing Matrix
|
## 📊 Testing Matrix
|
||||||
@@ -135,13 +137,13 @@ docker-compose logs backend | grep -i square
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check Square SDK installed
|
# Check Square SDK installed
|
||||||
docker-compose exec backend pip list | grep square
|
docker compose exec backend pip list | grep square
|
||||||
|
|
||||||
# Check configuration loaded
|
# 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
|
# 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
|
# Check frontend files
|
||||||
ls -la frontend/src/components/SquarePayment.tsx
|
ls -la frontend/src/components/SquarePayment.tsx
|
||||||
@@ -151,7 +153,7 @@ ls -la frontend/src/components/SquarePayment.tsx
|
|||||||
|
|
||||||
| Issue | Solution |
|
| 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_APPLICATION_ID not found" | Add to `.env` and restart containers |
|
||||||
| Square SDK not loading | Check browser console, verify script tag in index.html |
|
| Square SDK not loading | Check browser console, verify script tag in index.html |
|
||||||
| Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct |
|
| Payment fails with 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
|
- Users can retry failed payments
|
||||||
- Cash payments still work with PENDING status for admin approval
|
- Cash payments still work with PENDING status for admin approval
|
||||||
- All payment flows properly tested with Square sandbox test cards
|
- 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
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -66,13 +66,14 @@ SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID
|
|||||||
|
|
||||||
### 5. Restart the Application
|
### 5. Restart the Application
|
||||||
|
|
||||||
After updating the environment variables, restart your Docker containers:
|
After updating the environment variables, run the tested restart helper:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose down
|
./restart.sh
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Testing with Sandbox
|
||||||
|
|
||||||
Square provides test card numbers for sandbox testing:
|
Square provides test card numbers for sandbox testing:
|
||||||
|
|||||||
@@ -38,19 +38,21 @@ SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id...
|
|||||||
Run the deployment script:
|
Run the deployment script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy-square.sh
|
./restart.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
Or manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose down
|
docker compose build
|
||||||
docker-compose up -d --build
|
docker compose run --rm frontend npm test
|
||||||
|
docker compose run --rm backend pytest -q
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Test It Out!
|
### 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
|
2. Register/login
|
||||||
3. Go to "Setup Membership"
|
3. Go to "Setup Membership"
|
||||||
4. Select a tier
|
4. Select a tier
|
||||||
@@ -78,7 +80,7 @@ docker-compose up -d --build
|
|||||||
- ✅ `.env.example` - UPDATED
|
- ✅ `.env.example` - UPDATED
|
||||||
- ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
|
- ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
|
||||||
- ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
|
- ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
|
||||||
- ✅ `deploy-square.sh` - NEW (deployment helper)
|
- ✅ `restart.sh` - build, fast tests, and restart helper
|
||||||
|
|
||||||
## 🔧 Key Features
|
## 🔧 Key Features
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ User → Select Tier → Choose Payment Method
|
|||||||
|
|
||||||
### Backend won't start?
|
### Backend won't start?
|
||||||
```bash
|
```bash
|
||||||
docker-compose logs backend
|
docker compose logs backend
|
||||||
```
|
```
|
||||||
Check for missing dependencies or configuration errors.
|
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
|
1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions
|
||||||
2. Review Square's documentation
|
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
|
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 fastapi import APIRouter, Depends
|
||||||
from typing import Dict, Any
|
|
||||||
from app.services.feature_flag_service import feature_flags
|
from app.services.feature_flag_service import feature_flags
|
||||||
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
|
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
|
||||||
|
from app.api.dependencies import get_super_admin_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -38,10 +38,11 @@ async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/flags/reload")
|
@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
|
Reload feature flags from environment variables.
|
||||||
This could be protected with admin permissions in production
|
|
||||||
"""
|
"""
|
||||||
feature_flags.reload_flags()
|
feature_flags.reload_flags()
|
||||||
return {"message": "Feature flags reloaded successfully"}
|
return {"message": "Feature flags reloaded successfully"}
|
||||||
|
|||||||
+641
-17
@@ -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 fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...core.security import get_password_hash
|
from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
|
||||||
from ...models.models import User
|
from ...schemas import (
|
||||||
from ...schemas import UserResponse, UserUpdate, MessageResponse
|
MessageResponse,
|
||||||
|
ProfileAnswersUpdateRequest,
|
||||||
|
ProfileQuestionCreate,
|
||||||
|
ProfileQuestionForUser,
|
||||||
|
ProfileQuestionResponse,
|
||||||
|
ProfileQuestionUpdate,
|
||||||
|
UserResponse,
|
||||||
|
UserUpdate,
|
||||||
|
)
|
||||||
from ...api.dependencies import get_current_active_user, get_admin_user
|
from ...api.dependencies import get_current_active_user, get_admin_user
|
||||||
|
from ...services.email_service import email_service
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_current_user_profile(
|
async def get_current_user_profile(
|
||||||
current_user: User = Depends(get_current_active_user)
|
current_user: User = Depends(get_current_active_user)
|
||||||
@@ -27,25 +199,123 @@ async def update_current_user_profile(
|
|||||||
):
|
):
|
||||||
"""Update current user's profile"""
|
"""Update current user's profile"""
|
||||||
update_data = user_update.model_dump(exclude_unset=True)
|
update_data = user_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Check email uniqueness if email is being updated
|
# Prevent privilege and volunteer-level edits through self-service profile endpoint.
|
||||||
if 'email' in update_data and update_data['email'] != current_user.email:
|
update_data.pop("role", None)
|
||||||
existing_user = db.query(User).filter(User.email == update_data['email']).first()
|
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:
|
if existing_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Email already registered"
|
detail="Email already registered"
|
||||||
)
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(current_user, field, value)
|
setattr(current_user, field, value)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(current_user)
|
db.refresh(current_user)
|
||||||
|
|
||||||
return current_user
|
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])
|
@router.get("/", response_model=List[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -58,6 +328,281 @@ async def list_users(
|
|||||||
return 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)
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -88,18 +633,97 @@ async def update_user(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
update_data = user_update.model_dump(exclude_unset=True)
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
return 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)
|
@router.delete("/{user_id}", response_model=MessageResponse)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -113,8 +737,8 @@ async def delete_user(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
db.delete(user)
|
db.delete(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": "User deleted successfully"}
|
return {"message": "User deleted successfully"}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from sqlalchemy.orm import Session
|
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 .security import get_password_hash
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -70,3 +72,99 @@ def init_default_data(db: Session):
|
|||||||
db.add_all(default_templates)
|
db.add_all(default_templates)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"✓ Created {len(default_templates)} default email templates")
|
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,
|
VolunteerRole,
|
||||||
VolunteerAssignment,
|
VolunteerAssignment,
|
||||||
VolunteerSchedule,
|
VolunteerSchedule,
|
||||||
|
ProfileQuestion,
|
||||||
|
UserProfileAnswer,
|
||||||
Certificate,
|
Certificate,
|
||||||
File,
|
File,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -36,6 +38,8 @@ __all__ = [
|
|||||||
"VolunteerRole",
|
"VolunteerRole",
|
||||||
"VolunteerAssignment",
|
"VolunteerAssignment",
|
||||||
"VolunteerSchedule",
|
"VolunteerSchedule",
|
||||||
|
"ProfileQuestion",
|
||||||
|
"UserProfileAnswer",
|
||||||
"Certificate",
|
"Certificate",
|
||||||
"File",
|
"File",
|
||||||
"Notification",
|
"Notification",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
|
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
|
||||||
Float, Text, ForeignKey, Date
|
Float, Text, ForeignKey, Date, UniqueConstraint
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -60,6 +60,7 @@ class User(Base):
|
|||||||
phone = Column(String(20), nullable=True)
|
phone = Column(String(20), nullable=True)
|
||||||
address = Column(Text, 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)
|
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)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=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")
|
event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan")
|
||||||
volunteer_assignments = relationship("VolunteerAssignment", 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")
|
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):
|
class MembershipTier(Base):
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ from .schemas import (
|
|||||||
EventRSVPBase,
|
EventRSVPBase,
|
||||||
EventRSVPUpdate,
|
EventRSVPUpdate,
|
||||||
EventRSVPResponse,
|
EventRSVPResponse,
|
||||||
|
QuestionOption,
|
||||||
|
ProfileQuestionCreate,
|
||||||
|
ProfileQuestionUpdate,
|
||||||
|
ProfileQuestionResponse,
|
||||||
|
ProfileQuestionForUser,
|
||||||
|
ProfileAnswerUpdate,
|
||||||
|
ProfileAnswersUpdateRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -78,4 +85,11 @@ __all__ = [
|
|||||||
"EventRSVPBase",
|
"EventRSVPBase",
|
||||||
"EventRSVPUpdate",
|
"EventRSVPUpdate",
|
||||||
"EventRSVPResponse",
|
"EventRSVPResponse",
|
||||||
|
"QuestionOption",
|
||||||
|
"ProfileQuestionCreate",
|
||||||
|
"ProfileQuestionUpdate",
|
||||||
|
"ProfileQuestionResponse",
|
||||||
|
"ProfileQuestionForUser",
|
||||||
|
"ProfileAnswerUpdate",
|
||||||
|
"ProfileAnswersUpdateRequest",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||||
from typing import Optional
|
from typing import Optional, Literal, Any
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
|
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ class UserUpdate(BaseModel):
|
|||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
role: Optional[UserRole] = None
|
role: Optional[UserRole] = None
|
||||||
|
volunteer_level: Optional[str] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
@@ -31,6 +32,7 @@ class UserResponse(UserBase):
|
|||||||
|
|
||||||
id: int
|
id: int
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
volunteer_level: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[datetime] = None
|
||||||
@@ -285,3 +287,80 @@ class EventRSVPResponse(EventRSVPBase):
|
|||||||
attended: bool
|
attended: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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
|
aiofiles==23.2.1
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
pytest==8.3.4
|
||||||
|
|||||||
@@ -17,10 +17,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@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
+47
-14
@@ -6,26 +6,59 @@ import Login from './pages/Login';
|
|||||||
import ForgotPassword from './pages/ForgotPassword';
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import EmailTemplates from './pages/EmailTemplates';
|
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||||
import MembershipTiers from './pages/MembershipTiers';
|
import TermsOfService from './pages/TermsOfService';
|
||||||
import BounceManagement from './pages/BounceManagement';
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
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 (
|
return (
|
||||||
<FeatureFlagProvider>
|
<FeatureFlagProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<div className="app-shell">
|
||||||
<Route path="/" element={<Navigate to="/login" />} />
|
<main className="app-main">
|
||||||
<Route path="/register" element={<Register />} />
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/" element={<Navigate to="/login" />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/email-templates" element={<EmailTemplates />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/membership-tiers" element={<MembershipTiers />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/bounce-management" element={<BounceManagement />} />
|
<Route path="/email-templates" element={<Navigate to="/dashboard" />} />
|
||||||
</Routes>
|
<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>
|
</BrowserRouter>
|
||||||
</FeatureFlagProvider>
|
</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',
|
overflow: 'auto',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
lineHeight: '1.4',
|
lineHeight: '1.4',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
color: '#333'
|
color: '#333'
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: template.html_body.substring(0, 300) + '...' }}
|
>
|
||||||
/>
|
{template.html_body.substring(0, 300)}
|
||||||
|
{template.html_body.length > 300 ? '...' : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -398,4 +402,4 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EmailTemplateManagement;
|
export default EmailTemplateManagement;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface ProfileMenuProps {
|
|||||||
onEditProfile?: () => void;
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -135,42 +135,11 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onE
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* 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
|
<button
|
||||||
style={{
|
style={{
|
||||||
...menuItemStyle,
|
...menuItemStyle,
|
||||||
borderRadius: '0',
|
borderRadius: user ? '0' : '4px 4px 0 0',
|
||||||
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
|
borderTop: user ? '1px solid #eee' : 'none'
|
||||||
}}
|
}}
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
>
|
>
|
||||||
@@ -322,4 +291,4 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfileMenu;
|
export default ProfileMenu;
|
||||||
|
|||||||
@@ -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;
|
onCancelEdit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TierManagement: React.FC<TierManagementProps> = ({
|
export const TierManagement: React.FC<TierManagementProps> = ({
|
||||||
tiers,
|
tiers,
|
||||||
loading,
|
loading,
|
||||||
showCreateForm,
|
showCreateForm,
|
||||||
@@ -384,4 +384,4 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SuperAdminMenu;
|
export default SuperAdminMenu;
|
||||||
|
|||||||
+723
-422
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;
|
phone: string | null;
|
||||||
address: string | null;
|
address: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
|
volunteer_level: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login: string | null;
|
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 {
|
export interface MembershipTier {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -230,6 +281,51 @@ export const userService = {
|
|||||||
const response = await api.delete(`/users/${userId}`);
|
const response = await api.delete(`/users/${userId}`);
|
||||||
return response.data;
|
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 = {
|
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