From 632e66e21dcbe23724345192b88aa9d5991457a6 Mon Sep 17 00:00:00 2001 From: nathanb Date: Mon, 4 May 2026 22:05:58 +0100 Subject: [PATCH 1/2] 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 --- INSTRUCTIONS.md | 56 +- PROJECT_STRUCTURE.md | 186 +-- QUICKSTART.md | 38 +- README.md | 104 +- SQUARE_CHECKLIST.md | 28 +- SQUARE_IMPLEMENTATION.md | 9 + SQUARE_PAYMENT_SETUP.md | 7 +- SQUARE_QUICKSTART.md | 16 +- ...d_volunteer_level_and_profile_questions.py | 77 ++ backend/app/api/v1/feature_flags.py | 11 +- backend/app/api/v1/users.py | 658 +++++++++- backend/app/core/init_db.py | 100 +- backend/app/models/__init__.py | 4 + backend/app/models/models.py | 51 +- backend/app/schemas/__init__.py | 14 + backend/app/schemas/schemas.py | 81 +- backend/app/tests/conftest.py | 7 + .../app/tests/test_profile_question_logic.py | 113 ++ backend/requirements.txt | 3 + frontend/package.json | 6 +- frontend/src/App.css | 893 +++++++++++-- frontend/src/App.tsx | 61 +- .../AdminProfileQuestionManager.tsx | 410 ++++++ .../components/EmailTemplateManagement.tsx | 10 +- frontend/src/components/ProfileMenu.tsx | 39 +- .../src/components/ProfileQuestionsForm.tsx | 310 +++++ frontend/src/components/SuperAdminMenu.tsx | 4 +- frontend/src/pages/Dashboard.tsx | 1145 +++++++++++------ frontend/src/pages/PrivacyPolicy.tsx | 16 + frontend/src/pages/TermsOfService.tsx | 16 + frontend/src/services/membershipService.ts | 96 ++ .../src/utils/profileQuestionLogic.test.ts | 42 + frontend/src/utils/profileQuestionLogic.ts | 62 + restart.sh | 8 + 34 files changed, 3932 insertions(+), 749 deletions(-) create mode 100644 backend/alembic/versions/2e8a0f9d4b31_add_volunteer_level_and_profile_questions.py create mode 100644 backend/app/tests/conftest.py create mode 100644 backend/app/tests/test_profile_question_logic.py create mode 100644 frontend/src/components/AdminProfileQuestionManager.tsx create mode 100644 frontend/src/components/ProfileQuestionsForm.tsx create mode 100644 frontend/src/pages/PrivacyPolicy.tsx create mode 100644 frontend/src/pages/TermsOfService.tsx create mode 100644 frontend/src/utils/profileQuestionLogic.test.ts create mode 100644 frontend/src/utils/profileQuestionLogic.ts create mode 100755 restart.sh diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index f955683..90e8f79 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -4,25 +4,34 @@ This project aims to develop a comprehensive membership management system for the Swansea Airport Stakeholders' Alliance. The system will handle member registration, payment processing, membership tracking, and administrative functions for a medium-sized alliance. +## Current Implementation Status + +The app now includes a FastAPI backend, React/Vite frontend, Docker Compose development gateway, Alembic migrations, Square payment integration, SMTP2GO email integration, event/RSVP endpoints, configurable profile questions, privacy/terms pages, feature flags, and a fast test gate in `restart.sh`. + ## Core Features ### Public Member Features - **Self-Service Registration**: Members can sign up online and select their membership tier - **Payment Processing**: Integration with Square payment system for secure online payments, and a dummy payment system for initial testing - **Membership Portal**: Secure login to view membership status, payment history, and upcoming meetings -- **Renewal Reminders**: Automated email notifications for membership renewal deadlines +- **Profile Questions**: Members can answer configurable profile questions, including conditional and volunteering-related questions - **Event Management**: View upcoming events and RSVP to participate -- **Volunteering**: View assigned volunteer roles, schedule availability for roles, and access certificates/training records +- **Account Management**: Members can update profile details, change passwords, request password resets, and review privacy/terms pages +- **Renewal Reminders**: Planned automated email notifications for membership renewal deadlines +- **Volunteering**: Volunteer-related profile fields are implemented; richer role, schedule, and certificate screens are planned ### Administrative Features - **Member Database Management**: Query and modify member records - **Manual Payment Entry**: Record cash payments to activate memberships - **Membership Tier Management**: Configure different membership levels and associated fees -- **Meeting Management**: Post notices and updates about upcoming alliance meetings -- **Reporting**: Generate reports on membership statistics and payment status -- **Files**: A repositry for files which members can access based on their tier - such as meeting minutes and manuals. Admins can upload files to this area. +- **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions +- **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance +- **Email Management**: Edit database-backed email templates with escaped previews, send test emails, and monitor SMTP2GO bounces +- **Feature Flags**: View backend feature flags and reload them from the super-admin interface +- **Reporting**: Planned reports on membership statistics and payment status +- **Files**: Planned repository for member files based on tier, such as meeting minutes and manuals - **Event Management**: Create, edit, and manage events, track RSVPs and attendance -- **Volunteering**: Assign configurable volunteer roles to members (e.g., Fire, Radio, General), manage volunteer schedules, and record certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members. +- **Volunteering**: Models exist for configurable volunteer roles, assignments, schedules, and certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members. ## Technical Stack @@ -32,7 +41,8 @@ This project aims to develop a comprehensive membership management system for th - **Authentication**: JWT-based authentication system - **Payment Integration**: Square API for payment processing - **Email Service**: SMTP2GO API for automated reminders and notifications -- **Frontend**: Modern web interface (to be determined - potentially React/Vue.js) +- **Frontend**: React 18, TypeScript, Vite, and Tailwind CSS +- **Testing**: Vitest for frontend unit tests and pytest for backend unit tests ## Membership Tiers @@ -73,21 +83,41 @@ Each tier will have associated annual fees and benefits. - `memberships`: Membership records with tier and status - `payments`: Payment transactions - `tiers`: Membership tier definitions +- `profile_questions`: Configurable profile/onboarding questions +- `user_profile_answers`: Per-member profile answers - `events`: Event information and details - `event_rsvps`: Event registration and attendance tracking - `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General) - `volunteer_assignments`: Member-to-role assignments - `volunteer_schedules`: Volunteer shift scheduling and availability - `certificates`: Training certificates and qualifications +- `email_templates`: Editable SMTP2GO email templates +- `email_bounces`: Bounce/complaint/unsubscribe tracking +- `password_reset_tokens`: One-time reset tokens - `notifications`: Email notification logs +## Testing and Restart Workflow + +`./restart.sh` rebuilds Docker images with cache, runs the fast frontend and backend unit tests, shuts down the current stack, and starts it again only if tests pass. + +```bash +./restart.sh +``` + +Individual test commands: + +```bash +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +``` + ## Development Phases -1. **Phase 1**: Core API development (authentication, user management) -2. **Phase 2**: Payment integration and membership management -3. **Phase 3**: Admin interface development -4. **Phase 4**: Member portal, email system, event management, and volunteering features -5. **Phase 5**: Testing, deployment, and documentation +1. **Phase 1**: Core API development (authentication, user management) - implemented +2. **Phase 2**: Payment integration and membership management - implemented +3. **Phase 3**: Admin interface development - implemented for users, tiers, payments, emails, bounces, profile questions, and feature flags +4. **Phase 4**: Member portal, email system, event management, and volunteering features - partially implemented; richer volunteer screens and renewal reminders remain +5. **Phase 5**: Testing, deployment, and documentation - active; fast unit tests and documentation are in place ## Deployment Considerations @@ -152,4 +182,4 @@ Each tier will have associated annual fees and benefits. - Payment processing success rate - User engagement with portal - Administrative efficiency improvements -- System uptime and performance \ No newline at end of file +- System uptime and performance diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 8523436..b2d8637 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -2,115 +2,149 @@ ``` membership/ -├── .env # Environment configuration (ready to use) -├── .env.example # Template for environment variables +├── .env # Local environment configuration +├── .env.example # Environment variable template ├── .gitignore # Git ignore rules -├── docker-compose.yml # Docker services configuration -├── INSTRUCTIONS.md # Original project requirements -├── README.md # Complete documentation -├── QUICKSTART.md # Quick start guide +├── docker-compose.yml # Backend, frontend, gateway, and prod frontend services +├── restart.sh # Build, run fast tests, and restart the app +├── INSTRUCTIONS.md # Product requirements and roadmap context +├── README.md # Full project documentation +├── QUICKSTART.md # Short operator/developer guide │ ├── backend/ # FastAPI application -│ ├── Dockerfile # Backend container configuration -│ ├── requirements.txt # Python dependencies +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── alembic.ini +│ ├── alembic/ # Database migrations │ └── app/ -│ ├── __init__.py -│ ├── main.py # Application entry point -│ │ -│ ├── api/ # API endpoints -│ │ ├── __init__.py -│ │ ├── dependencies.py # Auth dependencies +│ ├── main.py # App, CORS, health check, router registration +│ ├── api/ +│ │ ├── dependencies.py # Auth dependencies │ │ └── v1/ -│ │ ├── __init__.py -│ │ ├── auth.py # Registration, login -│ │ ├── users.py # User management -│ │ ├── tiers.py # Membership tiers -│ │ ├── memberships.py # Membership management -│ │ └── payments.py # Payment processing -│ │ -│ ├── core/ # Core functionality -│ │ ├── __init__.py -│ │ ├── config.py # Configuration settings -│ │ ├── database.py # Database connection -│ │ └── security.py # Auth & password hashing -│ │ -│ ├── models/ # Database models -│ │ ├── __init__.py -│ │ └── models.py # SQLAlchemy models -│ │ -│ ├── schemas/ # Pydantic schemas -│ │ ├── __init__.py -│ │ └── schemas.py # Request/response schemas -│ │ -│ ├── services/ # Business logic (placeholder) -│ └── utils/ # Utilities (placeholder) +│ │ ├── auth.py # Register, login, password reset/change +│ │ ├── users.py # Users, profile questions, profile answers +│ │ ├── tiers.py # Membership tiers +│ │ ├── memberships.py +│ │ ├── payments.py # Manual, Square, refund, payment history +│ │ ├── email.py # SMTP2GO email tests and bounce webhooks +│ │ ├── email_templates.py +│ │ ├── events.py # Events and RSVPs +│ │ └── feature_flags.py +│ ├── core/ # Config, database, security, default data +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ ├── services/ # Email, bounce, Square, feature flags +│ └── tests/ # Fast backend pytest unit tests │ -├── database/ # Database initialization -│ └── init.sql # Default data & admin user +├── docker/ +│ └── gateway/ # Nginx dev gateway and self-signed TLS setup │ -└── frontend/ # Frontend (placeholder for future) +└── frontend/ # React/Vite frontend + ├── Dockerfile + ├── package.json + ├── vite.config.ts + └── src/ + ├── App.tsx # Routes, footer links, cookie notice + ├── components/ # Dashboard, admin, payment, email, profile UI + ├── contexts/ # Feature flag context/provider + ├── pages/ # Login, register, dashboard, policy pages + ├── services/ # API clients + └── utils/ # Shared frontend logic and Vitest tests ``` ## Key Files ### Configuration -- **`.env`** - Environment variables (database, API keys, etc.) -- **`docker-compose.yml`** - Services: MySQL + FastAPI backend +- **`.env`** - Runtime configuration for database, auth, Square, SMTP2GO, ports, and gateway TLS. +- **`docker-compose.yml`** - Services for FastAPI backend, Vite frontend, Nginx gateway, and production static frontend. +- **`restart.sh`** - Rebuilds images, runs frontend/backend unit tests, and restarts the stack only if tests pass. ### Backend Application -- **`backend/app/main.py`** - FastAPI app initialization, CORS, routes -- **`backend/app/core/config.py`** - Settings management -- **`backend/app/core/security.py`** - JWT tokens, password hashing -- **`backend/app/models/models.py`** - Database tables (User, Membership, Payment, etc.) -- **`backend/app/schemas/schemas.py`** - API request/response models +- **`backend/app/main.py`** - FastAPI app initialization, CORS, startup default-data seeding, routes, and health checks. +- **`backend/app/core/config.py`** - Settings management. +- **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions. +- **`backend/app/core/security.py`** - JWT tokens and password hashing. +- **`backend/app/models/models.py`** - Database tables. +- **`backend/app/schemas/schemas.py`** - API request/response models. +- **`backend/app/tests/test_profile_question_logic.py`** - Fast backend unit tests for profile answer validation. -### API Endpoints (v1) -- **`auth.py`** - Register, login -- **`users.py`** - User profile, admin user management -- **`tiers.py`** - Membership tier CRUD -- **`memberships.py`** - Membership management -- **`payments.py`** - Payment processing & history +### Frontend Application +- **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard. +- **`frontend/src/components/MembershipSetup.tsx`** - Membership tier selection and payment flow. +- **`frontend/src/components/SquarePayment.tsx`** - Square Web Payments SDK form. +- **`frontend/src/components/AdminProfileQuestionManager.tsx`** - Admin profile-question configuration. +- **`frontend/src/components/ProfileQuestionsForm.tsx`** - Member/admin answer form with dependency handling. +- **`frontend/src/components/EmailTemplateManagement.tsx`** - Email template editing. +- **`frontend/src/components/BounceManagement.tsx`** - SMTP2GO bounce management. +- **`frontend/src/utils/profileQuestionLogic.test.ts`** - Fast frontend unit tests for profile-question visibility/editability. + +## API Endpoints + +- **`auth.py`** - Register, login, forgot password, reset password, change password. +- **`users.py`** - Current user profile, admin user CRUD, profile-question CRUD, member/admin profile answers, and role-guarded admin password reset emails. +- **`tiers.py`** - Membership tier CRUD. +- **`memberships.py`** - Member/admin membership management. +- **`payments.py`** - Payment history, manual payments, Square config/process/refund. +- **`events.py`** - Event CRUD, upcoming events, RSVP create/update, RSVP listing. +- **`email.py`** - SMTP2GO test emails, welcome email tests, bounce webhook, bounce stats, cleanup, deactivation. +- **`email_templates.py`** - Database-backed template listing, lookup, update, and default seeding. +- **`feature_flags.py`** - Public feature flag listing/lookup and super-admin-only reload. ## Database Models Fully implemented: -- **User** - Authentication, profile, roles (member/admin/super_admin) -- **MembershipTier** - Configurable tiers with fees and benefits -- **Membership** - User memberships with status tracking -- **Payment** - Payment records with multiple methods -- **Event** - Event management (model ready, endpoints TODO) -- **EventRSVP** - Event registration (model ready, endpoints TODO) -- **VolunteerRole** - Volunteer roles (model ready, endpoints TODO) -- **VolunteerAssignment** - Role assignments (model ready, endpoints TODO) -- **VolunteerSchedule** - Shift scheduling (model ready, endpoints TODO) -- **Certificate** - Training certificates (model ready, endpoints TODO) -- **File** - File repository (model ready, endpoints TODO) -- **Notification** - Email tracking (model ready, endpoints TODO) +- **User** - Authentication, profile, roles, volunteer level. +- **ProfileQuestion** - Configurable profile fields, options, dependencies, admin-only edit flags. +- **UserProfileAnswer** - Per-user answers with update attribution. +- **MembershipTier** - Configurable tiers with fees and benefits. +- **Membership** - User memberships with status, dates, and auto-renew flag. +- **Payment** - Payment records for Square, cash, check, and dummy methods. +- **Event** - Event management records. +- **EventRSVP** - RSVP and attendance records. +- **EmailTemplate** - Editable database-backed email templates. +- **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking. +- **PasswordResetToken** - One-time password reset support. +- **VolunteerRole** - Volunteer role definitions. +- **VolunteerAssignment** - Member-to-role assignments. +- **VolunteerSchedule** - Volunteer shift schedules. +- **Certificate** - Training/certificate records. +- **File** - File repository metadata. +- **Notification** - Email notification logs. ## Quick Start ```bash # Start everything -docker-compose up -d +docker compose up -d # View logs -docker-compose logs -f +docker compose logs -f # Access API docs -# http://localhost:8000/docs +# http://localhost:8050/docs +``` + +## Tests + +```bash +# Run both fast test suites and restart only if they pass +./restart.sh + +# Run test suites individually +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q ``` ## Default Credentials **Admin**: admin@swanseaairport.org / admin123 -**Database**: Configured via environment variables (see .env file) +**Database**: Configured via environment variables in `.env`. -## What's Next +## Remaining Roadmap -1. Test the API endpoints -2. Add Square payment integration -3. Implement email notifications -4. Create event management endpoints -5. Add volunteer management endpoints -6. Build frontend interface +1. Expand authenticated API tests for member/admin workflows +2. Add member file repository endpoints and UI +3. Build richer volunteer assignment, schedule, and certificate screens +4. Add renewal reminder batch jobs +5. Add reporting and analytics diff --git a/QUICKSTART.md b/QUICKSTART.md index 46eb821..0232b96 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -21,6 +21,20 @@ For Square payment form testing, use HTTPS at `https://localhost:8443`. Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`. TLS certs are auto-generated by the gateway container on first start. +## Restart With Tests + +Use the restart helper when you want to rebuild, run the fast test suite, and restart only after tests pass: + +```bash +./restart.sh +``` + +It runs: +- `docker compose run --rm frontend npm test` +- `docker compose run --rm backend pytest -q` + +The current tests cover frontend profile-question visibility/editability rules and backend profile-question answer normalization/validation. They are designed to complete quickly. + ## Testing the API ### 1. Register a new user @@ -108,6 +122,26 @@ docker compose logs -f gateway 1. Login as admin 2. GET `/api/v1/users/` +### Manage profile questions (admin) +1. Login as admin or super admin +2. Open the dashboard Admin area +3. Create, edit, deactivate, and order configurable profile questions +4. Use dependencies to show questions only after a matching parent answer + +### Edit member profile answers +1. Members can update normal profile questions from the Questions dashboard tab +2. Admin-only answers, such as verified training fields, must be updated by an admin + +### Manage events and RSVPs +1. Admins can create and edit events from the dashboard +2. Members can view upcoming events and submit RSVP status +3. Admins can view RSVP lists and attendance data + +### Manage email templates and bounces +1. Super admins can edit database-backed email templates; previews are shown as escaped HTML text +2. SMTP2GO bounce webhooks are stored and visible in bounce management +3. Bounce cleanup and manual deactivation are available through the API/admin screens + ## Troubleshooting ### Check service status @@ -143,4 +177,6 @@ docker compose up -d 3. Create additional admin users 4. Configure membership tiers as needed 5. Test payment processing -6. Customize email templates (coming soon) +6. Customize email templates +7. Configure profile questions for onboarding and volunteer data +8. Use `./restart.sh` before deploying changes so frontend and backend unit tests run first diff --git a/README.md b/README.md index 0da27fb..5b9acd3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,32 @@ # Swansea Airport Stakeholders' Alliance Membership Management System -A comprehensive membership management system built with FastAPI, MySQL, and Docker. +A membership management system for Swansea Airport Stakeholders' Alliance, built with FastAPI, React, MySQL-compatible storage, Square payments, SMTP2GO email services, and Docker Compose. ## Features -- **User Management**: Registration, authentication, and profile management -- **Membership Tiers**: Configurable membership levels with different benefits and fees -- **Payment Processing**: Support for Square payments, cash, and check payments -- **Admin Dashboard**: Complete administrative control over members and payments -- **Event Management**: Create and manage events with RSVP tracking (coming soon) -- **Volunteer Management**: Role assignments, scheduling, and certificates (coming soon) -- **Email Notifications**: Automated notifications via SMTP2GO (coming soon) +- **Authentication and accounts**: Registration, JSON/form login, JWT sessions, password reset, password change, and role-based access for members, admins, and super admins. +- **Member portal**: Dashboard with membership status, payment history, membership setup, account settings, profile editing, configurable profile questions, cookie notice, privacy policy, and terms of service pages. +- **Admin operations**: User listing/editing, admin-triggered member password reset emails, membership tier CRUD, manual payment recording, Square refunds, email template editing with escaped previews, SMTP2GO bounce management, profile-question management, and super-admin feature-flag reloads. +- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits. +- **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking. +- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility. +- **Volunteer and profile data**: Volunteer flag/level support, configurable member profile questions, conditional questions, admin-only answers, seeded aviation/volunteering questions, and data models for volunteer roles, assignments, schedules, and certificates. +- **Email system**: SMTP2GO-backed email sending, default database templates, editable templates, welcome/password-reset/test emails, bounce webhooks, bounce stats, cleanup, and manual deactivation. +- **Feature flags**: Backend feature-flag service with frontend context and admin status/reload controls. +- **Testing**: Fast frontend Vitest unit tests and backend pytest unit tests wired into `restart.sh`. ## Tech Stack - **Backend**: FastAPI (Python 3.11) +- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS - **Database**: MySQL 8.0 - **Authentication**: JWT tokens with OAuth2 - **Containerization**: Docker & Docker Compose - **ORM**: SQLAlchemy +- **Migrations**: Alembic +- **Payments**: Square Web Payments SDK and Square API +- **Email**: SMTP2GO +- **Tests**: Vitest and pytest ## Project Structure @@ -37,7 +45,11 @@ membership/ │ │ │ │ ├── users.py # User management │ │ │ │ ├── tiers.py # Membership tiers │ │ │ │ ├── memberships.py # Membership management -│ │ │ │ └── payments.py # Payment processing +│ │ │ │ ├── payments.py # Payment processing +│ │ │ │ ├── email.py # SMTP2GO email and bounces +│ │ │ │ ├── email_templates.py +│ │ │ │ ├── events.py # Events and RSVPs +│ │ │ │ └── feature_flags.py │ │ │ └── dependencies.py # Auth dependencies │ │ ├── core/ │ │ │ ├── config.py # Configuration @@ -50,8 +62,13 @@ membership/ │ │ └── main.py # Application entry point │ ├── Dockerfile │ └── requirements.txt -├── database/ -│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations) +├── frontend/ +│ ├── src/ +│ │ ├── components/ # Dashboard, payment, admin, profile components +│ │ ├── contexts/ # Feature flag context +│ │ ├── pages/ # Login, register, dashboard, policy pages +│ │ ├── services/ # API clients +│ │ └── utils/ # Tested frontend logic ├── docker-compose.yml ├── .env.example └── README.md @@ -95,6 +112,25 @@ membership/ - API Documentation: http://localhost:8050/docs - TLS certs are generated automatically by the gateway container on first start +## Restart and Test Gate + +`restart.sh` rebuilds images with cache, runs the fast frontend and backend unit tests, then restarts the stack only if tests pass: + +```bash +./restart.sh +``` + +The current fast test suite covers: +- frontend profile-question visibility and editability rules with Vitest +- backend profile-question option parsing, answer normalization/deserialization, select validation, and volunteer flag normalization with pytest + +You can also run them individually: + +```bash +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +``` + ## Frontend Development vs Production ### Development Mode (Vite) @@ -191,6 +227,39 @@ docker compose --profile prod down - `PUT /api/v1/payments/{id}` - Update payment (admin) - `GET /api/v1/payments/` - List all payments (admin) - `POST /api/v1/payments/manual-payment` - Record manual payment (admin) +- `GET /api/v1/payments/config/square` - Get frontend Square config +- `POST /api/v1/payments/square/process` - Process Square card payment +- `POST /api/v1/payments/square/refund` - Refund Square payment (admin) + +### Profile Questions +- `GET /api/v1/users/me/profile-questions` - List active questions with current answers +- `PUT /api/v1/users/me/profile-answers` - Update editable answers +- `GET /api/v1/users/admin/profile-questions` - List all profile questions (admin) +- `POST /api/v1/users/admin/profile-questions` - Create profile question (admin) +- `PUT /api/v1/users/admin/profile-questions/{id}` - Update profile question (admin) +- `DELETE /api/v1/users/admin/profile-questions/{id}` - Deactivate profile question (admin) +- `GET /api/v1/users/admin/users/{id}/profile-answers` - View user answers (admin) +- `PUT /api/v1/users/admin/users/{id}/profile-answers` - Update user answers (admin) + +### Events +- `GET /api/v1/events/` - List events +- `GET /api/v1/events/upcoming` - List upcoming events +- `POST /api/v1/events/` - Create event (admin) +- `PUT /api/v1/events/{id}` - Update event (admin) +- `DELETE /api/v1/events/{id}` - Delete event (admin) +- `GET /api/v1/events/{id}/rsvps` - List RSVPs (admin) +- `POST /api/v1/events/{id}/rsvp` - Create or update current user's RSVP + +### Email and Feature Flags +- `POST /api/v1/email/test-email` - Send test email +- `POST /api/v1/email/test-welcome-email` - Send test welcome email +- `POST /api/v1/email/webhooks/smtp2go/bounce` - Receive SMTP2GO bounce webhook +- `GET /api/v1/email/bounces` - List bounces +- `GET /api/v1/email/bounces/stats` - Bounce statistics +- `GET /api/v1/email-templates/` - List templates +- `PUT /api/v1/email-templates/{template_key}` - Update template +- `GET /api/v1/feature-flags/flags` - List flags +- `POST /api/v1/feature-flags/flags/reload` - Reload flags (super admin) ## Docker Compose Commands @@ -362,16 +431,13 @@ docker compose up -d docker compose logs -f ``` -## Next Steps +## Remaining Roadmap -- [ ] Implement Square payment integration -- [ ] Add email notification system -- [ ] Create event management endpoints -- [ ] Add volunteer management features -- [ ] Build frontend interface -- [ ] Add file upload/management -- [ ] Implement automated renewal reminders +- [ ] Add member file upload/repository endpoints and UI +- [ ] Add richer volunteer role, assignment, schedule, and certificate screens on top of the existing models +- [ ] Implement automated renewal reminder batch jobs - [ ] Add reporting and analytics +- [ ] Expand test coverage around authenticated API flows and payment/email service boundaries ## License diff --git a/SQUARE_CHECKLIST.md b/SQUARE_CHECKLIST.md index 24cd111..b598392 100644 --- a/SQUARE_CHECKLIST.md +++ b/SQUARE_CHECKLIST.md @@ -36,7 +36,7 @@ - [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide - [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details - [x] Created `SQUARE_QUICKSTART.md` - Quick start guide -- [x] Created `deploy-square.sh` - Deployment helper script +- [x] Updated `restart.sh` - Build, fast tests, and restart helper ### Code Quality - [x] No Python syntax errors @@ -64,13 +64,15 @@ Before deploying, complete these steps: - [ ] Set SQUARE_ENVIRONMENT=sandbox ### 3. Deployment -- [ ] Run `./deploy-square.sh` OR -- [ ] Run `docker-compose down` -- [ ] Run `docker-compose up -d --build` -- [ ] Verify containers are running: `docker-compose ps` +- [ ] Run `./restart.sh` OR +- [ ] Run `docker compose build` +- [ ] Run `docker compose run --rm frontend npm test` +- [ ] Run `docker compose run --rm backend pytest -q` +- [ ] Run `docker compose up -d` +- [ ] Verify containers are running: `docker compose ps` ### 4. Testing -- [ ] Access frontend at http://localhost:3000 +- [ ] Access frontend at http://localhost:8050 or HTTPS at https://localhost:8443 - [ ] Login/register a user - [ ] Navigate to membership setup - [ ] Select a membership tier @@ -104,7 +106,7 @@ After deployment, run these commands to verify: ```bash # Check backend is running -curl http://localhost:8000/api/v1/payments/config/square +curl http://localhost:8050/api/v1/payments/config/square # Expected output (with your actual IDs): # { @@ -114,10 +116,10 @@ curl http://localhost:8000/api/v1/payments/config/square # } # Check frontend is running -curl http://localhost:3000 +curl http://localhost:8050 # Check logs -docker-compose logs backend | grep -i square +docker compose logs backend | grep -i square ``` ## 📊 Testing Matrix @@ -135,13 +137,13 @@ docker-compose logs backend | grep -i square ```bash # Check Square SDK installed -docker-compose exec backend pip list | grep square +docker compose exec backend pip list | grep square # Check configuration loaded -docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)" +docker compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)" # Check database has payments -docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;" +docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;" # Check frontend files ls -la frontend/src/components/SquarePayment.tsx @@ -151,7 +153,7 @@ ls -la frontend/src/components/SquarePayment.tsx | Issue | Solution | |-------|----------| -| "Module not found: squareup" | Rebuild backend: `docker-compose build backend` | +| "Module not found: squareup" | Rebuild backend: `docker compose build backend` | | "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers | | Square SDK not loading | Check browser console, verify script tag in index.html | | Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct | diff --git a/SQUARE_IMPLEMENTATION.md b/SQUARE_IMPLEMENTATION.md index e662158..ce317ac 100644 --- a/SQUARE_IMPLEMENTATION.md +++ b/SQUARE_IMPLEMENTATION.md @@ -193,6 +193,15 @@ The Square payment integration is complete, tested, and working in sandbox mode: - Users can retry failed payments - Cash payments still work with PENDING status for admin approval - All payment flows properly tested with Square sandbox test cards +- `restart.sh` now runs the fast Vitest and pytest suites before restarting the stack + +Fast verification commands: + +```bash +./restart.sh +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +``` ## Summary diff --git a/SQUARE_PAYMENT_SETUP.md b/SQUARE_PAYMENT_SETUP.md index 6f2e250..de688bb 100644 --- a/SQUARE_PAYMENT_SETUP.md +++ b/SQUARE_PAYMENT_SETUP.md @@ -66,13 +66,14 @@ SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID ### 5. Restart the Application -After updating the environment variables, restart your Docker containers: +After updating the environment variables, run the tested restart helper: ```bash -docker-compose down -docker-compose up -d --build +./restart.sh ``` +For a manual restart, run `docker compose build`, `docker compose run --rm frontend npm test`, `docker compose run --rm backend pytest -q`, and then `docker compose up -d`. + ## Testing with Sandbox Square provides test card numbers for sandbox testing: diff --git a/SQUARE_QUICKSTART.md b/SQUARE_QUICKSTART.md index 537988e..2398c7e 100644 --- a/SQUARE_QUICKSTART.md +++ b/SQUARE_QUICKSTART.md @@ -38,19 +38,21 @@ SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id... Run the deployment script: ```bash -./deploy-square.sh +./restart.sh ``` Or manually: ```bash -docker-compose down -docker-compose up -d --build +docker compose build +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +docker compose up -d ``` ### Step 4: Test It Out! -1. Open http://localhost:3000 +1. Open http://localhost:8050 or https://localhost:8443 for HTTPS Square testing 2. Register/login 3. Go to "Setup Membership" 4. Select a tier @@ -78,7 +80,7 @@ docker-compose up -d --build - ✅ `.env.example` - UPDATED - ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide) - ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details) -- ✅ `deploy-square.sh` - NEW (deployment helper) +- ✅ `restart.sh` - build, fast tests, and restart helper ## 🔧 Key Features @@ -118,7 +120,7 @@ User → Select Tier → Choose Payment Method ### Backend won't start? ```bash -docker-compose logs backend +docker compose logs backend ``` Check for missing dependencies or configuration errors. @@ -156,7 +158,7 @@ When ready for production payments: 1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions 2. Review Square's documentation -3. Check application logs: `docker-compose logs -f backend` +3. Check application logs: `docker compose logs -f backend` 4. Contact Square support for payment-specific issues --- diff --git a/backend/alembic/versions/2e8a0f9d4b31_add_volunteer_level_and_profile_questions.py b/backend/alembic/versions/2e8a0f9d4b31_add_volunteer_level_and_profile_questions.py new file mode 100644 index 0000000..d9d90a0 --- /dev/null +++ b/backend/alembic/versions/2e8a0f9d4b31_add_volunteer_level_and_profile_questions.py @@ -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') diff --git a/backend/app/api/v1/feature_flags.py b/backend/app/api/v1/feature_flags.py index 696481e..1809316 100644 --- a/backend/app/api/v1/feature_flags.py +++ b/backend/app/api/v1/feature_flags.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends -from typing import Dict, Any from app.services.feature_flag_service import feature_flags from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse +from app.api.dependencies import get_super_admin_user router = APIRouter() @@ -38,10 +38,11 @@ async def get_feature_flag(flag_name: str) -> FeatureFlagResponse: @router.post("/flags/reload") -async def reload_feature_flags(): +async def reload_feature_flags( + current_user = Depends(get_super_admin_user), +): """ - Reload feature flags from environment variables - This could be protected with admin permissions in production + Reload feature flags from environment variables. """ feature_flags.reload_flags() - return {"message": "Feature flags reloaded successfully"} \ No newline at end of file + return {"message": "Feature flags reloaded successfully"} diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 5626eea..f9c9da8 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,16 +1,188 @@ +import json +from datetime import date, datetime, timedelta +from typing import Any, List, Optional +import uuid + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from typing import List from ...core.database import get_db -from ...core.security import get_password_hash -from ...models.models import User -from ...schemas import UserResponse, UserUpdate, MessageResponse +from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken +from ...schemas import ( + MessageResponse, + ProfileAnswersUpdateRequest, + ProfileQuestionCreate, + ProfileQuestionForUser, + ProfileQuestionResponse, + ProfileQuestionUpdate, + UserResponse, + UserUpdate, +) from ...api.dependencies import get_current_active_user, get_admin_user +from ...services.email_service import email_service router = APIRouter() +def _parse_options(options_json: Optional[str]) -> list[dict[str, str]]: + if not options_json: + return [] + try: + parsed = json.loads(options_json) + except (TypeError, json.JSONDecodeError): + return [] + + if not isinstance(parsed, list): + return [] + + normalized: list[dict[str, str]] = [] + for item in parsed: + if not isinstance(item, dict): + continue + label = str(item.get("label", "")).strip() + value = str(item.get("value", "")).strip() + if label and value: + normalized.append({"label": label, "value": value}) + return normalized + + +def _serialize_options(options: Optional[list[Any]]) -> Optional[str]: + if not options: + return None + normalized = [] + for item in options: + data = item.model_dump() if hasattr(item, "model_dump") else item + normalized.append({"label": str(data["label"]), "value": str(data["value"])}) + return json.dumps(normalized) + + +def _normalize_answer_value(question: ProfileQuestion, value: Any) -> Optional[str]: + if value is None: + return None + + if isinstance(value, str) and value.strip() == "": + return None + + input_type = question.input_type + + if input_type == "boolean": + if isinstance(value, bool): + return "true" if value else "false" + + text = str(value).strip().lower() + if text in {"true", "1", "yes", "y"}: + return "true" + if text in {"false", "0", "no", "n"}: + return "false" + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid boolean answer for question '{question.key}'" + ) + + if input_type == "number": + try: + number = float(value) + return str(int(number)) if number.is_integer() else str(number) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid number answer for question '{question.key}'" + ) + + if input_type == "date": + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + + text = str(value).strip() + try: + parsed = datetime.strptime(text, "%Y-%m-%d") + return parsed.date().isoformat() + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid date answer for question '{question.key}'. Use YYYY-MM-DD" + ) + + if input_type == "select": + selected = str(value).strip() + option_values = {opt["value"] for opt in _parse_options(question.options_json)} + if selected not in option_values: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid selection for question '{question.key}'" + ) + return selected + + return str(value).strip() + + +def _deserialize_answer_value(question: ProfileQuestion, value_text: Optional[str]) -> Any: + if value_text is None: + return None + + if question.input_type == "boolean": + return value_text.lower() == "true" + + if question.input_type == "number": + try: + number = float(value_text) + return int(number) if number.is_integer() else number + except ValueError: + return value_text + + return value_text + + +def _validate_question_dependencies( + db: Session, + depends_on_question_id: Optional[int], + depends_on_value: Optional[str], + current_question_id: Optional[int] = None, +) -> None: + if depends_on_question_id is None: + if depends_on_value is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="depends_on_value requires depends_on_question_id" + ) + return + + dependent_question = db.query(ProfileQuestion).filter(ProfileQuestion.id == depends_on_question_id).first() + if not dependent_question: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="depends_on_question_id does not exist" + ) + + if current_question_id is not None and current_question_id == depends_on_question_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A question cannot depend on itself" + ) + + +def _normalize_volunteer_level(value: Optional[str]) -> Optional[str]: + if value is None: + return None + + normalized = str(value).strip().lower() + if normalized == "": + return None + + if normalized in {"yes", "true", "1"}: + return "yes" + if normalized in {"no", "false", "0"}: + return "no" + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Volunteer flag must be yes or no" + ) + + @router.get("/me", response_model=UserResponse) async def get_current_user_profile( current_user: User = Depends(get_current_active_user) @@ -27,25 +199,123 @@ async def update_current_user_profile( ): """Update current user's profile""" update_data = user_update.model_dump(exclude_unset=True) - - # Check email uniqueness if email is being updated - if 'email' in update_data and update_data['email'] != current_user.email: - existing_user = db.query(User).filter(User.email == update_data['email']).first() + + # Prevent privilege and volunteer-level edits through self-service profile endpoint. + update_data.pop("role", None) + update_data.pop("volunteer_level", None) + + if "email" in update_data and update_data["email"] != current_user.email: + existing_user = db.query(User).filter(User.email == update_data["email"]).first() if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) - + for field, value in update_data.items(): setattr(current_user, field, value) - + db.commit() db.refresh(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]) async def list_users( skip: int = 0, @@ -58,6 +328,281 @@ async def list_users( return users +@router.get("/admin/profile-questions", response_model=List[ProfileQuestionResponse]) +async def list_profile_questions_admin( + include_inactive: bool = True, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + query = db.query(ProfileQuestion) + if not include_inactive: + query = query.filter(ProfileQuestion.is_active == True) + + questions = query.order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all() + + return [ + ProfileQuestionResponse( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + ) + for question in questions + ] + + +@router.post("/admin/profile-questions", response_model=ProfileQuestionResponse) +async def create_profile_question_admin( + payload: ProfileQuestionCreate, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + if payload.input_type == "select" and not payload.options: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Select questions require options" + ) + + _validate_question_dependencies(db, payload.depends_on_question_id, payload.depends_on_value) + + existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == payload.key).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Question key already exists" + ) + + question = ProfileQuestion( + key=payload.key, + label=payload.label, + help_text=payload.help_text, + input_type=payload.input_type, + placeholder=payload.placeholder, + options_json=_serialize_options(payload.options), + is_required=payload.is_required, + is_active=payload.is_active, + admin_only_edit=payload.admin_only_edit, + display_order=payload.display_order, + depends_on_question_id=payload.depends_on_question_id, + depends_on_value=payload.depends_on_value, + ) + + db.add(question) + db.commit() + db.refresh(question) + + return ProfileQuestionResponse( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + ) + + +@router.put("/admin/profile-questions/{question_id}", response_model=ProfileQuestionResponse) +async def update_profile_question_admin( + question_id: int, + payload: ProfileQuestionUpdate, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first() + if not question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Question not found" + ) + + update_data = payload.model_dump(exclude_unset=True) + + if "key" in update_data and update_data["key"] != question.key: + existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == update_data["key"]).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Question key already exists" + ) + + input_type = update_data.get("input_type", question.input_type) + options = update_data.get("options") + options_to_validate = options if options is not None else _parse_options(question.options_json) + if input_type == "select" and not options_to_validate: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Select questions require options" + ) + + depends_on_question_id = update_data.get("depends_on_question_id", question.depends_on_question_id) + depends_on_value = update_data.get("depends_on_value", question.depends_on_value) + _validate_question_dependencies(db, depends_on_question_id, depends_on_value, current_question_id=question.id) + + for field, value in update_data.items(): + if field == "options": + question.options_json = _serialize_options(value) + else: + setattr(question, field, value) + + db.commit() + db.refresh(question) + + return ProfileQuestionResponse( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + ) + + +@router.delete("/admin/profile-questions/{question_id}", response_model=MessageResponse) +async def deactivate_profile_question_admin( + question_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first() + if not question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Question not found" + ) + + question.is_active = False + db.commit() + + return {"message": "Question deactivated successfully"} + + +@router.get("/admin/users/{user_id}/profile-answers", response_model=List[ProfileQuestionForUser]) +async def get_user_profile_answers_admin( + user_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + questions = db.query(ProfileQuestion).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all() + answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == user_id).all() + answers_by_question = {answer.question_id: answer for answer in answers} + + return [ + ProfileQuestionForUser( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + answer=_deserialize_answer_value(question, answers_by_question.get(question.id).value_text if answers_by_question.get(question.id) else None), + can_edit=True, + ) + for question in questions + ] + + +@router.put("/admin/users/{user_id}/profile-answers", response_model=MessageResponse) +async def update_user_profile_answers_admin( + user_id: int, + payload: ProfileAnswersUpdateRequest, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if not payload.answers: + return {"message": "No changes submitted"} + + question_ids = {item.question_id for item in payload.answers} + questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids)).all() + questions_by_id = {question.id: question for question in questions} + + missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id] + if missing_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Questions not found: {missing_ids}" + ) + + for item in payload.answers: + question = questions_by_id[item.question_id] + normalized_value = _normalize_answer_value(question, item.value) + + answer = db.query(UserProfileAnswer).filter( + UserProfileAnswer.user_id == user_id, + UserProfileAnswer.question_id == question.id + ).first() + + if normalized_value is None: + if answer: + db.delete(answer) + continue + + if answer: + answer.value_text = normalized_value + answer.updated_by_user_id = current_user.id + else: + db.add(UserProfileAnswer( + user_id=user_id, + question_id=question.id, + value_text=normalized_value, + updated_by_user_id=current_user.id, + )) + + db.commit() + return {"message": "User profile answers updated successfully"} + + @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, @@ -88,18 +633,97 @@ async def update_user( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - + update_data = user_update.model_dump(exclude_unset=True) - + + if "email" in update_data and update_data["email"] != user.email: + existing_user = db.query(User).filter(User.email == update_data["email"]).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + if "role" in update_data and update_data["role"] == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only super admins can assign super admin role" + ) + + if "volunteer_level" in update_data: + update_data["volunteer_level"] = _normalize_volunteer_level(update_data["volunteer_level"]) + for field, value in update_data.items(): setattr(user, field, value) - + db.commit() db.refresh(user) - + return user +@router.post("/{user_id}/send-password-reset", response_model=MessageResponse) +async def send_user_password_reset( + user_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + """Send a one-time password reset link email for a user.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN] and current_user.role != UserRole.SUPER_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only super admins can send password reset emails for admin users" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot reset password for inactive user" + ) + + db.query(PasswordResetToken).filter( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used == False, + PasswordResetToken.expires_at > datetime.utcnow() + ).update({"used": True}) + + reset_token = str(uuid.uuid4()) + expires_at = datetime.utcnow() + timedelta(hours=1) + + db_token = PasswordResetToken( + user_id=user.id, + token=reset_token, + expires_at=expires_at, + used=False + ) + + db.add(db_token) + db.commit() + + try: + await email_service.send_password_reset_email( + to_email=user.email, + first_name=user.first_name, + reset_token=reset_token, + db=db + ) + except Exception as exc: + print(f"Failed to send admin password reset email: {exc}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send reset email" + ) + + return {"message": "One-time password reset email sent successfully"} + + @router.delete("/{user_id}", response_model=MessageResponse) async def delete_user( user_id: int, @@ -113,8 +737,8 @@ async def delete_user( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - + db.delete(user) db.commit() - + return {"message": "User deleted successfully"} diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py index ac42ebc..7c9d7d3 100644 --- a/backend/app/core/init_db.py +++ b/backend/app/core/init_db.py @@ -1,5 +1,7 @@ from sqlalchemy.orm import Session -from ..models.models import MembershipTier, User, UserRole, EmailTemplate +import json + +from ..models.models import MembershipTier, User, UserRole, EmailTemplate, ProfileQuestion from .security import get_password_hash from datetime import datetime @@ -70,3 +72,99 @@ def init_default_data(db: Session): db.add_all(default_templates) db.commit() print(f"✓ Created {len(default_templates)} default email templates") + + # Seed default profile questions for onboarding and profile attributes + existing_questions = db.query(ProfileQuestion).count() + if existing_questions == 0: + print("Creating default profile questions...") + default_questions = [ + ProfileQuestion( + key="has_professional_license", + label="Do you hold a professional aviation-related license?", + help_text="Select your current license status.", + input_type="select", + options_json=json.dumps([ + {"label": "No", "value": "none"}, + {"label": "Student", "value": "student"}, + {"label": "Private Pilot", "value": "ppl"}, + {"label": "Commercial Pilot", "value": "cpl"}, + {"label": "ATPL", "value": "atpl"}, + {"label": "Instructor", "value": "instructor"}, + ]), + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=10, + ), + ProfileQuestion( + key="license_number", + label="License number", + help_text="Optional: your current license number.", + input_type="text", + placeholder="e.g. UK.FCL.123456", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=20, + depends_on_value="ppl", + ), + ProfileQuestion( + key="can_support_events", + label="Can you support airport or membership events?", + help_text="Choose yes if you're open to helping with events.", + input_type="boolean", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=30, + ), + ProfileQuestion( + key="event_support_notes", + label="What support can you offer?", + help_text="Examples: stewarding, admin desk, setup/packdown, mentoring.", + input_type="text", + placeholder="Type details here", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=40, + depends_on_value="true", + ), + ProfileQuestion( + key="hours_available_monthly", + label="Approximate volunteer hours available each month", + help_text="Optional estimate in hours.", + input_type="number", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=50, + ), + ProfileQuestion( + key="medical_expiry_date", + label="Medical certificate expiry date", + help_text="Optional date in YYYY-MM-DD format.", + input_type="date", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=60, + ), + ProfileQuestion( + key="completed_training_x", + label="Completed Training X", + help_text="This is set by admins once verified.", + input_type="boolean", + is_required=False, + is_active=True, + admin_only_edit=True, + display_order=70, + ), + ] + db.add_all(default_questions) + db.commit() + question_by_key = {question.key: question for question in db.query(ProfileQuestion).all()} + question_by_key["license_number"].depends_on_question_id = question_by_key["has_professional_license"].id + question_by_key["event_support_notes"].depends_on_question_id = question_by_key["can_support_events"].id + db.commit() + print(f"✓ Created {len(default_questions)} default profile questions") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8189865..f823eee 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,8 @@ from .models import ( VolunteerRole, VolunteerAssignment, VolunteerSchedule, + ProfileQuestion, + UserProfileAnswer, Certificate, File, Notification, @@ -36,6 +38,8 @@ __all__ = [ "VolunteerRole", "VolunteerAssignment", "VolunteerSchedule", + "ProfileQuestion", + "UserProfileAnswer", "Certificate", "File", "Notification", diff --git a/backend/app/models/models.py b/backend/app/models/models.py index d5160ed..6d89c9f 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,6 +1,6 @@ from sqlalchemy import ( Column, Integer, String, DateTime, Boolean, Enum as SQLEnum, - Float, Text, ForeignKey, Date + Float, Text, ForeignKey, Date, UniqueConstraint ) from sqlalchemy.orm import relationship from datetime import datetime @@ -60,6 +60,7 @@ class User(Base): phone = Column(String(20), nullable=True) address = Column(Text, nullable=True) role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False) + volunteer_level = Column(String(50), nullable=True) is_active = Column(Boolean, default=True, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -71,6 +72,54 @@ class User(Base): event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan") volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan") certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan") + profile_answers = relationship( + "UserProfileAnswer", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="UserProfileAnswer.user_id" + ) + + +class ProfileQuestion(Base): + __tablename__ = "profile_questions" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(100), unique=True, nullable=False, index=True) + label = Column(String(255), nullable=False) + help_text = Column(Text, nullable=True) + input_type = Column(String(30), nullable=False) # text, number, boolean, date, select + placeholder = Column(String(255), nullable=True) + options_json = Column(Text, nullable=True) # JSON array: [{"label":"Yes","value":"true"}] + is_required = Column(Boolean, default=False, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + admin_only_edit = Column(Boolean, default=False, nullable=False) + display_order = Column(Integer, default=0, nullable=False) + depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True) + depends_on_value = Column(String(255), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions") + answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan") + + +class UserProfileAnswer(Base): + __tablename__ = "user_profile_answers" + __table_args__ = ( + UniqueConstraint("user_id", "question_id", name="uq_user_profile_answer"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True) + value_text = Column(Text, nullable=True) + updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers") + question = relationship("ProfileQuestion", back_populates="answers") + updated_by_user = relationship("User", foreign_keys=[updated_by_user_id]) class MembershipTier(Base): diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 97b5489..911e19a 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -37,6 +37,13 @@ from .schemas import ( EventRSVPBase, EventRSVPUpdate, EventRSVPResponse, + QuestionOption, + ProfileQuestionCreate, + ProfileQuestionUpdate, + ProfileQuestionResponse, + ProfileQuestionForUser, + ProfileAnswerUpdate, + ProfileAnswersUpdateRequest, ) __all__ = [ @@ -78,4 +85,11 @@ __all__ = [ "EventRSVPBase", "EventRSVPUpdate", "EventRSVPResponse", + "QuestionOption", + "ProfileQuestionCreate", + "ProfileQuestionUpdate", + "ProfileQuestionResponse", + "ProfileQuestionForUser", + "ProfileAnswerUpdate", + "ProfileAnswersUpdateRequest", ] diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index a90dba5..9fe5a39 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, EmailStr, Field, ConfigDict -from typing import Optional +from typing import Optional, Literal, Any from datetime import datetime, date from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod @@ -24,6 +24,7 @@ class UserUpdate(BaseModel): phone: Optional[str] = None address: Optional[str] = None role: Optional[UserRole] = None + volunteer_level: Optional[str] = Field(None, max_length=50) class UserResponse(UserBase): @@ -31,6 +32,7 @@ class UserResponse(UserBase): id: int role: UserRole + volunteer_level: Optional[str] = None is_active: bool created_at: datetime last_login: Optional[datetime] = None @@ -285,3 +287,80 @@ class EventRSVPResponse(EventRSVPBase): attended: bool created_at: datetime updated_at: datetime + + +# Profile Question Schemas +ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"] + + +class QuestionOption(BaseModel): + label: str = Field(..., min_length=1, max_length=100) + value: str = Field(..., min_length=1, max_length=100) + + +class ProfileQuestionBase(BaseModel): + key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") + label: str = Field(..., min_length=2, max_length=255) + help_text: Optional[str] = None + input_type: ProfileQuestionInputType + placeholder: Optional[str] = Field(None, max_length=255) + options: Optional[list[QuestionOption]] = None + is_required: bool = False + is_active: bool = True + admin_only_edit: bool = False + display_order: int = 0 + depends_on_question_id: Optional[int] = None + depends_on_value: Optional[str] = Field(None, max_length=255) + + +class ProfileQuestionCreate(ProfileQuestionBase): + pass + + +class ProfileQuestionUpdate(BaseModel): + key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") + label: Optional[str] = Field(None, min_length=2, max_length=255) + help_text: Optional[str] = None + input_type: Optional[ProfileQuestionInputType] = None + placeholder: Optional[str] = Field(None, max_length=255) + options: Optional[list[QuestionOption]] = None + is_required: Optional[bool] = None + is_active: Optional[bool] = None + admin_only_edit: Optional[bool] = None + display_order: Optional[int] = None + depends_on_question_id: Optional[int] = None + depends_on_value: Optional[str] = Field(None, max_length=255) + + +class ProfileQuestionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + key: str + label: str + help_text: Optional[str] = None + input_type: ProfileQuestionInputType + placeholder: Optional[str] = None + options: list[QuestionOption] = [] + is_required: bool + is_active: bool + admin_only_edit: bool + display_order: int + depends_on_question_id: Optional[int] = None + depends_on_value: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class ProfileQuestionForUser(ProfileQuestionResponse): + answer: Optional[Any] = None + can_edit: bool = True + + +class ProfileAnswerUpdate(BaseModel): + question_id: int + value: Optional[Any] = None + + +class ProfileAnswersUpdateRequest(BaseModel): + answers: list[ProfileAnswerUpdate] diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py new file mode 100644 index 0000000..bfea3d2 --- /dev/null +++ b/backend/app/tests/conftest.py @@ -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)) diff --git a/backend/app/tests/test_profile_question_logic.py b/backend/app/tests/test_profile_question_logic.py new file mode 100644 index 0000000..0649000 --- /dev/null +++ b/backend/app/tests/test_profile_question_logic.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index f44c322..671bf43 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,3 +28,6 @@ email-validator==2.1.0 aiofiles==23.2.1 Jinja2==3.1.2 python-dateutil==2.8.2 + +# Tests +pytest==8.3.4 diff --git a/frontend/package.json b/frontend/package.json index 4aa6cbe..fbc8c07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,10 +17,12 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.0", - "vite": "^5.0.5" + "vite": "^5.0.5", + "vitest": "^1.6.1" } } diff --git a/frontend/src/App.css b/frontend/src/App.css index 2454953..5f58f00 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3,13 +3,34 @@ @tailwind utilities; :root { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: "IBM Plex Sans", "Segoe UI", "Helvetica Neue", Arial, sans-serif; line-height: 1.6; font-weight: 400; - color: #213547; - background-color: #ffffff; + color: #263445; + background-color: #eef4f3; + --ops-bg: #eef4f3; + --ops-surface: #ffffff; + --ops-surface-muted: #f5f9fb; + --ops-surface-strong: #e3f0f2; + --ops-border: #c9d8df; + --ops-border-soft: #d7e4ea; + --ops-text: #263445; + --ops-muted: #5b6674; + --ops-subtle: #667386; + --ops-accent: #0b6f8f; + --ops-accent-dark: #084f67; + --ops-accent-soft: #e2f3f8; + --ops-accent-mid: #b7dfea; + --ops-accent-wash: #f0faf9; + --ops-coral: #c94f5c; + --ops-coral-soft: #fdebed; + --ops-danger: #b42336; + --ops-danger-soft: #fde8ec; + --ops-warning: #9a6a00; + --ops-warning-soft: #fff5da; + --ops-success: #176c48; + --ops-success-soft: #e4f6ed; + --ops-radius: 6px; } * { @@ -20,56 +41,79 @@ body { min-height: 100vh; + background: + linear-gradient(180deg, rgba(226, 243, 248, 0.72) 0, rgba(238, 244, 243, 0.96) 240px, var(--ops-bg) 100%); + color: var(--ops-text); +} + +#root { + min-height: 100vh; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-main { + flex: 1 0 auto; } .container { - max-width: 1200px; + max-width: 1280px; margin: 0 auto; - padding: 20px; + padding: 20px 18px; } .card { - background: white; - border-radius: 8px; - padding: 24px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: linear-gradient(180deg, var(--ops-surface) 0%, #fbfdfd 100%); + border-radius: var(--ops-radius); + padding: 22px; + box-shadow: none; + border: 1px solid var(--ops-border-soft); + border-top: 3px solid var(--ops-accent-mid); margin-bottom: 20px; } .btn { - padding: 10px 20px; - border: none; - border-radius: 4px; - font-size: 16px; + padding: 9px 15px; + border: 1px solid transparent; + border-radius: var(--ops-radius); + font-size: 15px; cursor: pointer; - transition: all 0.3s; + transition: all 0.2s; + font-weight: 600; } .btn-primary { - background-color: #0066cc; + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); color: white; } .btn-primary:hover { - background-color: #0052a3; + background-color: var(--ops-accent-dark); } .btn-secondary { - background-color: #6c757d; - color: white; + background-color: var(--ops-surface); + border-color: var(--ops-border); + color: var(--ops-text); } .btn-secondary:hover { - background-color: #5a6268; + background-color: var(--ops-accent-soft); + border-color: var(--ops-accent); + color: var(--ops-accent); } .btn-danger { - background-color: #dc3545; + background-color: var(--ops-danger); color: white; } .btn-danger:hover { - background-color: #c82333; + background-color: #8f1d2d; } .form-group { @@ -79,22 +123,28 @@ body { .form-group label { display: block; margin-bottom: 6px; - font-weight: 500; + font-weight: 700; + color: var(--ops-text); } .form-group input, -.form-group textarea { +.form-group textarea, +.form-group select { width: 100%; padding: 10px; - border: 1px solid #ddd; - border-radius: 4px; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); font-size: 16px; + background: var(--ops-surface); + color: var(--ops-text); } .form-group input:focus, -.form-group textarea:focus { +.form-group textarea:focus, +.form-group select:focus { outline: none; - border-color: #0066cc; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); } .alert { @@ -104,49 +154,53 @@ body { } .alert-success { - background-color: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; + background-color: var(--ops-success-soft); + color: var(--ops-success); + border: 1px solid #b9e6cc; } .alert-error { - background-color: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; + background-color: var(--ops-coral-soft); + color: #8c2631; + border: 1px solid #f2c5ca; } .alert-warning { - background-color: #fff3cd; - color: #856404; - border: 1px solid #ffeaa7; + background-color: var(--ops-warning-soft); + color: var(--ops-warning); + border: 1px solid #efd080; } .navbar { - background-color: #0066cc; - color: white; - padding: 16px 24px; + background: linear-gradient(90deg, #f7fcfb 0%, #e8f5f7 56%, #fff8e8 100%); + color: var(--ops-text); + padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + box-shadow: none; + border-bottom: 1px solid #bdd7df; } .navbar h1 { margin: 0; font-size: 20px; + color: var(--ops-accent-dark); } .navbar button { - background: rgba(255, 255, 255, 0.2); - color: white; - border: none; + background: var(--ops-surface-muted); + color: var(--ops-text); + border: 1px solid var(--ops-border); padding: 8px 16px; - border-radius: 4px; + border-radius: var(--ops-radius); cursor: pointer; } .navbar button:hover { - background: rgba(255, 255, 255, 0.3); + background: var(--ops-accent-soft); + border-color: #b8d5e4; + color: var(--ops-accent); } .auth-container { @@ -154,32 +208,37 @@ body { display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: + linear-gradient(135deg, rgba(226, 243, 248, 0.92) 0%, rgba(255, 245, 218, 0.72) 100%), + var(--ops-bg); + padding: 24px; } .auth-card { - background: white; - border-radius: 8px; - padding: 40px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); + border-radius: var(--ops-radius); + padding: 34px; + box-shadow: none; + border: 1px solid #bfd7df; + border-top: 4px solid var(--ops-accent); width: 100%; max-width: 900px; } .auth-card h2 { margin-bottom: 24px; - color: #213547; + color: var(--ops-text); text-align: center; } .auth-card .form-footer { margin-top: 16px; text-align: center; - color: #666; + color: var(--ops-muted); } .auth-card .form-footer a { - color: #0066cc; + color: var(--ops-accent); text-decoration: none; } @@ -191,9 +250,485 @@ body { display: grid; grid-template-columns: 1fr; gap: 20px; + margin-top: 16px; +} + +.dashboard-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.navbar-main { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.navbar-tab-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.navbar-tab-active, +.navbar-tab-inactive { + border: 1px solid var(--ops-border); + color: var(--ops-text); + border-radius: var(--ops-radius); + padding: 6px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.navbar-tab-active { + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); + border-color: var(--ops-accent-dark); + color: white; +} + +.navbar-tab-inactive { + background: rgba(255, 255, 255, 0.72); +} + +.navbar-tab-inactive:hover { + background: var(--ops-accent-soft); + border-color: #b8d5e4; + color: var(--ops-accent); +} + +.profile-question-row { + display: grid; + grid-template-columns: minmax(240px, 320px) 1fr; + gap: 14px; + align-items: start; + border: 1px solid var(--ops-border-soft); + border-radius: var(--ops-radius); + padding: 12px 12px 10px; + background: linear-gradient(90deg, var(--ops-accent-wash) 0%, var(--ops-surface) 36%); +} + +.profile-question-meta { + min-width: 0; +} + +.profile-question-answer { + min-width: 0; +} + +.profile-question-readonly { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid #d5dde8; + border-radius: var(--ops-radius); + background: var(--ops-surface-muted); + color: var(--ops-muted); + font-weight: 600; +} + +.site-footer { + margin-top: auto; + padding: 20px 14px; + background: linear-gradient(90deg, #f7fcfb 0%, #eef7f8 62%, #fff8e8 100%); + color: var(--ops-muted); + border-top: 1px solid #bdd7df; + text-align: center; + font-size: 14px; +} + +.site-footer a { + color: var(--ops-accent); + text-decoration: none; + margin: 0 8px; +} + +.site-footer a:hover { + text-decoration: underline; +} + +.cookie-banner { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 2400; + width: min(360px, calc(100vw - 32px)); + background: linear-gradient(180deg, #ffffff 0%, #f4fbfa 100%); + color: var(--ops-text); + border-radius: var(--ops-radius); + border: 1px solid #b9d8df; + border-left: 4px solid var(--ops-accent); + box-shadow: 0 8px 18px rgba(38, 52, 69, 0.12); + padding: 12px 14px; + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + font-size: 13px; + line-height: 1.45; +} + +.admin-workspace { + display: grid; + grid-template-columns: 230px minmax(0, 1fr); + gap: 18px; + align-items: start; margin-top: 20px; } +.admin-sidebar { + position: sticky; + top: 18px; + display: grid; + gap: 6px; + background: linear-gradient(180deg, #ffffff 0%, #f4fbfa 100%); + border: 1px solid #bdd7df; + border-radius: var(--ops-radius); + padding: 12px; + box-shadow: none; +} + +.admin-sidebar-title { + color: var(--ops-muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 4px 6px 10px; +} + +.admin-sidebar-link { + width: 100%; + border: 1px solid transparent; + background: transparent; + color: var(--ops-text); + border-radius: var(--ops-radius); + padding: 10px 11px; + text-align: left; + font-weight: 700; + cursor: pointer; +} + +.admin-sidebar-link:hover, +.admin-sidebar-link.active { + background: var(--ops-accent-soft); + border-color: #b8d5e4; + color: var(--ops-accent); +} + +.admin-sidebar-link.active { + border-left: 4px solid var(--ops-accent); + padding-left: 8px; +} + +.admin-content { + min-width: 0; +} + +.admin-page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + margin-bottom: 16px; + border-bottom: 1px solid #cfe1e5; + padding-bottom: 12px; +} + +.admin-page-header h3 { + margin: 0 0 4px; +} + +.admin-page-header p { + margin: 0; + color: var(--ops-muted); +} + +.admin-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 18px; +} + +.admin-stat-card { + display: grid; + gap: 6px; + background: linear-gradient(180deg, #ffffff 0%, #f8fcfb 100%); + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); + padding: 14px; + text-align: left; + color: var(--ops-text); + box-shadow: none; +} + +button.admin-stat-card { + cursor: pointer; +} + +.admin-stat-card span { + color: var(--ops-muted); + font-size: 13px; + font-weight: 700; +} + +.admin-stat-card strong { + font-size: 26px; + line-height: 1; + color: var(--ops-accent-dark); +} + +.admin-stat-card.attention { + border-color: #d2aa4d; + background: var(--ops-warning-soft); +} + +.admin-panel-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 14px; +} + +.admin-panel { + background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); + border: 1px solid var(--ops-border-soft); + border-top: 3px solid var(--ops-accent-mid); + border-radius: var(--ops-radius); + padding: 18px; + box-shadow: none; +} + +.admin-panel h4 { + margin: 0 0 12px; +} + +.admin-queue-list { + display: grid; + gap: 10px; +} + +.admin-queue-item { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + border: 1px solid var(--ops-border-soft); + border-radius: var(--ops-radius); + padding: 10px; + background: linear-gradient(90deg, #ffffff 0%, #f6fbfa 100%); +} + +.admin-queue-item span, +.muted-line { + display: block; + color: var(--ops-subtle); + font-size: 12px; + margin-top: 2px; +} + +.admin-filter-bar { + display: grid; + grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(150px, 180px)); + gap: 10px; + margin-bottom: 12px; +} + +.admin-filter-bar input, +.admin-filter-bar select, +.drawer-control-grid select { + width: 100%; + padding: 9px 10px; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); + background: var(--ops-surface); + color: var(--ops-text); +} + +.admin-filter-bar input:focus, +.admin-filter-bar select:focus, +.drawer-control-grid select:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 111, 143, 0.12); +} + +.admin-table-wrap { + overflow-x: auto; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); + background: var(--ops-surface); +} + +.admin-table { + width: 100%; + min-width: 820px; + border-collapse: collapse; +} + +.admin-table th, +.admin-table td { + padding: 10px 12px; + border-bottom: 1px solid #edf1f6; + text-align: left; + vertical-align: top; +} + +.admin-table th { + color: var(--ops-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; + background: #e9f4f7; +} + +.admin-table tbody tr { + cursor: pointer; +} + +.admin-table tbody tr:hover { + background: var(--ops-accent-wash); +} + +.admin-table .btn, +.table-button-row .btn { + padding: 5px 9px; + font-size: 12px; +} + +.table-button-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.admin-pagination { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 12px; + color: var(--ops-muted); + font-size: 13px; +} + +.admin-pagination div { + display: flex; + gap: 8px; +} + +.admin-empty { + color: var(--ops-subtle); + padding: 12px 0; +} + +.role-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 800; + text-transform: capitalize; +} + +.role-member { + background: var(--ops-success-soft); + color: var(--ops-success); +} + +.role-admin { + background: var(--ops-warning-soft); + color: var(--ops-warning); +} + +.role-super_admin { + background: var(--ops-coral-soft); + color: #9f1f2d; +} + +.drawer-overlay { + position: fixed; + inset: 0; + z-index: 2600; + background: rgba(38, 52, 69, 0.32); + display: flex; + justify-content: flex-end; +} + +.user-drawer { + width: min(760px, 100vw); + height: 100vh; + overflow-y: auto; + background: + linear-gradient(180deg, rgba(226, 243, 248, 0.72) 0, var(--ops-bg) 220px); + box-shadow: -12px 0 24px rgba(38, 52, 69, 0.16); + padding: 22px; +} + +.drawer-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.drawer-header h3 { + margin: 0 0 4px; +} + +.drawer-header p { + margin: 0; + color: var(--ops-muted); +} + +.drawer-header-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; +} + +.drawer-close { + border: none; + background: transparent; + color: var(--ops-muted); + font-size: 26px; + line-height: 1; + cursor: pointer; +} + +.drawer-section { + background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); + border: 1px solid var(--ops-border-soft); + border-top: 3px solid var(--ops-accent-mid); + border-radius: var(--ops-radius); + padding: 16px; + margin-bottom: 14px; +} + +.drawer-section h4 { + margin: 0 0 12px; +} + +.drawer-control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.drawer-control-grid label { + display: grid; + gap: 5px; + font-weight: 700; + color: var(--ops-text); +} + /* Desktop view: side-by-side layout */ @media (min-width: 769px) { .dashboard-grid { @@ -213,7 +748,7 @@ body { } .container { - padding: 10px; + padding: 10px 8px; } .card { @@ -231,7 +766,7 @@ body { /* Make tables responsive */ table { width: 100%; - min-width: 600px; /* Ensure minimum width for readability */ + min-width: 520px; } .table-container { @@ -268,29 +803,84 @@ body { gap: 16px !important; } } + + .dashboard-tabs { + gap: 6px; + } + + .navbar-main { + width: 100%; + justify-content: space-between; + } + + .navbar-tab-strip { + width: 100%; + } + + .profile-question-row { + grid-template-columns: 1fr; + } + + .cookie-banner { + right: 10px; + bottom: 10px; + width: min(360px, calc(100vw - 20px)); + flex-direction: column; + align-items: flex-start; + } + + .admin-workspace { + grid-template-columns: 1fr; + } + + .admin-sidebar { + position: static; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-sidebar-title { + grid-column: 1 / -1; + } + + .admin-filter-bar { + grid-template-columns: 1fr; + } + + .admin-page-header, + .admin-queue-item, + .admin-pagination { + align-items: stretch; + flex-direction: column; + } + + .user-drawer { + width: 100vw; + padding: 16px; + } } .status-badge { display: inline-block; - padding: 4px 12px; - border-radius: 12px; - font-size: 14px; - font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; } .status-active { - background-color: #d4edda; - color: #155724; + background-color: var(--ops-success-soft); + color: var(--ops-success); } .status-pending { - background-color: #fff3cd; - color: #856404; + background-color: var(--ops-warning-soft); + color: var(--ops-warning); } .status-expired { - background-color: #f8d7da; - color: #721c24; + background-color: var(--ops-danger-soft); + color: var(--ops-danger); } /* Modal Styles */ @@ -300,7 +890,7 @@ body { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(38, 52, 69, 0.32); display: flex; align-items: center; justify-content: center; @@ -308,17 +898,19 @@ body { } .modal-content { - background: white; + background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); padding: 24px; - border-radius: 8px; + border-radius: var(--ops-radius); width: 100%; max-width: 400px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + box-shadow: 0 12px 24px rgba(38, 52, 69, 0.14); + border: 1px solid #bfd7df; + border-top: 4px solid var(--ops-accent); } .modal-content h3 { margin: 0 0 20px 0; - color: #333; + color: var(--ops-text); font-size: 18px; font-weight: bold; } @@ -331,27 +923,28 @@ body { display: block; margin-bottom: 4px; font-weight: bold; - color: #333; + color: var(--ops-text); font-size: 14px; } .modal-form-group input { width: 100%; padding: 8px; - border: 1px solid #ddd; - border-radius: 4px; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); font-size: 16px; - color: #333; - background-color: #fff; + color: var(--ops-text); + background-color: var(--ops-surface); } .modal-form-group input:focus { outline: none; - border-color: #0066cc; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); } .modal-error { - color: #dc3545; + color: var(--ops-danger); margin-bottom: 16px; font-size: 14px; } @@ -364,30 +957,30 @@ body { .modal-btn-cancel { padding: 8px 16px; - border: 1px solid #ddd; - border-radius: 4px; - background: white; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); + background: var(--ops-surface); cursor: pointer; - color: #333; + color: var(--ops-text); font-size: 14px; } .modal-btn-cancel:hover { - background: #f8f9fa; + background: var(--ops-surface-muted); } .modal-btn-primary { padding: 8px 16px; border: none; - border-radius: 4px; - background: #007bff; + border-radius: var(--ops-radius); + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); color: white; cursor: pointer; font-size: 14px; } .modal-btn-primary:hover:not(:disabled) { - background: #0056b3; + background: var(--ops-accent-dark); } .modal-btn-primary:disabled { @@ -397,27 +990,29 @@ body { /* Tab Styles */ .tab-active { - padding: 10px 20px; - border: none; - background: #007bff; + padding: 9px 14px; + border: 1px solid var(--ops-accent-dark); + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); color: white; cursor: pointer; - border-bottom: 2px solid #007bff; - font-weight: bold; + border-radius: var(--ops-radius); + font-weight: 600; } .tab-inactive { - padding: 10px 20px; - border: none; - background: none; - color: #666; + padding: 9px 14px; + border: 1px solid var(--ops-border); + background: var(--ops-surface); + color: var(--ops-text); cursor: pointer; - border-bottom: 2px solid transparent; + border-radius: var(--ops-radius); + font-weight: 600; } .tab-inactive:hover { - color: #007bff; - border-bottom-color: #007bff; + color: var(--ops-accent); + border-color: #b8d5e4; + background: var(--ops-accent-soft); } /* Super Admin Panel Styles */ @@ -441,9 +1036,9 @@ body { /* Action buttons in tables */ .action-btn { padding: 6px 12px; - border: 1px solid #007bff; - border-radius: 4px; - background: #007bff; + border: 1px solid var(--ops-accent); + border-radius: var(--ops-radius); + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); color: white !important; cursor: pointer; font-size: 12px; @@ -454,20 +1049,20 @@ body { } .action-btn:hover { - background: #0056b3; - border-color: #0056b3; + background: var(--ops-accent-dark); + border-color: var(--ops-accent-dark); color: white !important; } .action-btn-danger { - border-color: #dc3545; - background: #dc3545; + border-color: var(--ops-coral); + background: var(--ops-coral); color: white !important; } .action-btn-danger:hover { - background: #c82333; - border-color: #c82333; + background: #9f2f3d; + border-color: #9f2f3d; color: white !important; } @@ -479,10 +1074,11 @@ body { } .event-card { - border: 1px solid #ddd; - border-radius: 8px; + border: 1px solid var(--ops-border-soft); + border-radius: var(--ops-radius); padding: 16px; - background-color: #f9f9f9; + background: linear-gradient(90deg, #ffffff 0%, #f4fbfa 100%); + border-left: 4px solid var(--ops-accent-mid); } .event-header { @@ -500,7 +1096,7 @@ body { .event-title { margin: 0 0 4px 0; - color: #0066cc; + color: var(--ops-accent); font-size: 18px; word-wrap: break-word; } @@ -508,13 +1104,13 @@ body { .event-datetime { margin: 0; font-size: 14px; - color: #666; + color: var(--ops-muted); } .event-location { margin: 4px 0 0 0; font-size: 14px; - color: #666; + color: var(--ops-muted); } .event-rsvp-buttons { @@ -607,6 +1203,75 @@ body { color: #721c24; } +input, +textarea, +select { + font: inherit; +} + +button, +a, +input, +textarea, +select { + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} + +table { + border-collapse: collapse; +} + +th { + background: var(--ops-surface-muted); + color: var(--ops-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +td, +th { + border-bottom: 1px solid #edf1f6; +} + +.card h2, +.card h3, +.card h4, +.admin-panel h3, +.admin-panel h4, +.drawer-section h4 { + color: var(--ops-text); +} + +.card p, +.event-description { + color: var(--ops-text); +} + +.welcome-section, +.auth-card > div[style*="linear-gradient"], +.card div[style*="backgroundColor: '#f5f5f5'"], +.card div[style*="background-color: #f5f5f5"] { + background: var(--ops-surface-muted) !important; + border: 1px solid var(--ops-border-soft) !important; + border-radius: var(--ops-radius) !important; + box-shadow: none !important; +} + +.rsvp-btn { + border-radius: var(--ops-radius); +} + +.rsvp-btn.active { + transform: none; +} + +.rsvp-btn-attending.active, +.rsvp-btn-maybe.active, +.rsvp-btn-not-attending.active { + box-shadow: none; +} + /* Mobile responsive adjustments for events */ @media (max-width: 768px) { .event-header { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b7eb4b..dc145bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,26 +6,59 @@ import Login from './pages/Login'; import ForgotPassword from './pages/ForgotPassword'; import ResetPassword from './pages/ResetPassword'; import Dashboard from './pages/Dashboard'; -import EmailTemplates from './pages/EmailTemplates'; -import MembershipTiers from './pages/MembershipTiers'; -import BounceManagement from './pages/BounceManagement'; +import PrivacyPolicy from './pages/PrivacyPolicy'; +import TermsOfService from './pages/TermsOfService'; import './App.css'; +import { useState } from 'react'; +import { Link } from 'react-router-dom'; const App: React.FC = () => { + const [cookieDismissed, setCookieDismissed] = useState( + () => localStorage.getItem('cookie_notice_dismissed') === 'true' + ); + + const dismissCookies = () => { + localStorage.setItem('cookie_notice_dismissed', 'true'); + setCookieDismissed(true); + }; + return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ Privacy Policy + Terms of Service +
+
SASA Portal
+
+ {!cookieDismissed && ( +
+
+ We use cookies for session authentication, security, and basic site functionality. +
+ +
+ )} +
); diff --git a/frontend/src/components/AdminProfileQuestionManager.tsx b/frontend/src/components/AdminProfileQuestionManager.tsx new file mode 100644 index 0000000..e98bb5b --- /dev/null +++ b/frontend/src/components/AdminProfileQuestionManager.tsx @@ -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 = ({ onQuestionsChanged }) => { + const [questions, setQuestions] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [editingQuestionId, setEditingQuestionId] = useState(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(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 ( +
+

Profile Questions (Admin)

+

+ Manage the set of profile questions users can answer. You can add follow-up questions with dependencies. +

+ + {error &&
{error}
} + +
+ setFormData((prev) => ({ ...prev, key: event.target.value }))} + style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} + /> + setFormData((prev) => ({ ...prev, label: event.target.value }))} + style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} + /> +