Compare commits
5 Commits
main
..
f068a863e0
| Author | SHA1 | Date | |
|---|---|---|---|
| f068a863e0 | |||
| d024bf7fa3 | |||
| 34489fd7b7 | |||
| 1a0b4dc25d | |||
| 632e66e21d |
@@ -6,6 +6,7 @@ __pycache__/
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
build/
|
||||
develop-eggs/
|
||||
|
||||
+43
-13
@@ -4,25 +4,34 @@
|
||||
|
||||
This project aims to develop a comprehensive membership management system for the Swansea Airport Stakeholders' Alliance. The system will handle member registration, payment processing, membership tracking, and administrative functions for a medium-sized alliance.
|
||||
|
||||
## 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
|
||||
- System uptime and performance
|
||||
|
||||
+110
-76
@@ -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
|
||||
|
||||
+37
-1
@@ -21,6 +21,20 @@ For Square payment form testing, use HTTPS at `https://localhost:8443`.
|
||||
Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`.
|
||||
TLS certs are auto-generated by the gateway container on first start.
|
||||
|
||||
## Restart With Tests
|
||||
|
||||
Use the restart helper when you want to rebuild, run the fast test suite, and restart only after tests pass:
|
||||
|
||||
```bash
|
||||
./restart.sh
|
||||
```
|
||||
|
||||
It runs:
|
||||
- `docker compose run --rm frontend npm test`
|
||||
- `docker compose run --rm backend pytest -q`
|
||||
|
||||
The current tests cover frontend profile-question visibility/editability rules and backend profile-question answer normalization/validation. They are designed to complete quickly.
|
||||
|
||||
## Testing the API
|
||||
|
||||
### 1. Register a new user
|
||||
@@ -108,6 +122,26 @@ docker compose logs -f gateway
|
||||
1. Login as admin
|
||||
2. GET `/api/v1/users/`
|
||||
|
||||
### Manage profile questions (admin)
|
||||
1. Login as admin or super admin
|
||||
2. Open the dashboard Admin area
|
||||
3. Create, edit, deactivate, and order configurable profile questions
|
||||
4. Use dependencies to show questions only after a matching parent answer
|
||||
|
||||
### Edit member profile answers
|
||||
1. Members can update normal profile questions from the Questions dashboard tab
|
||||
2. Admin-only answers, such as verified training fields, must be updated by an admin
|
||||
|
||||
### Manage events and RSVPs
|
||||
1. Admins can create and edit events from the dashboard
|
||||
2. Members can view upcoming events and submit RSVP status
|
||||
3. Admins can view RSVP lists and attendance data
|
||||
|
||||
### Manage email templates and bounces
|
||||
1. Super admins can edit database-backed email templates; previews are shown as escaped HTML text
|
||||
2. SMTP2GO bounce webhooks are stored and visible in bounce management
|
||||
3. Bounce cleanup and manual deactivation are available through the API/admin screens
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check service status
|
||||
@@ -143,4 +177,6 @@ docker compose up -d
|
||||
3. Create additional admin users
|
||||
4. Configure membership tiers as needed
|
||||
5. Test payment processing
|
||||
6. Customize email templates (coming soon)
|
||||
6. Customize email templates
|
||||
7. Configure profile questions for onboarding and volunteer data
|
||||
8. Use `./restart.sh` before deploying changes so frontend and backend unit tests run first
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
# Swansea Airport Stakeholders' Alliance Membership Management System
|
||||
|
||||
A comprehensive membership management system built with FastAPI, MySQL, and Docker.
|
||||
A membership management system for Swansea Airport Stakeholders' Alliance, built with FastAPI, React, MySQL-compatible storage, Square payments, SMTP2GO email services, and Docker Compose.
|
||||
|
||||
## Features
|
||||
|
||||
- **User Management**: Registration, authentication, and profile management
|
||||
- **Membership Tiers**: Configurable membership levels with different benefits and fees
|
||||
- **Payment Processing**: Support for Square payments, cash, and check payments
|
||||
- **Admin Dashboard**: Complete administrative control over members and payments
|
||||
- **Event Management**: Create and manage events with RSVP tracking (coming soon)
|
||||
- **Volunteer Management**: Role assignments, scheduling, and certificates (coming soon)
|
||||
- **Email Notifications**: Automated notifications via SMTP2GO (coming soon)
|
||||
- **Authentication and accounts**: Registration, JSON/form login, JWT sessions, password reset, password change, and role-based access for members, admins, and super admins.
|
||||
- **Member portal**: Dashboard with membership status, payment history, membership setup, account settings, profile editing, configurable profile questions, cookie notice, privacy policy, and terms of service pages.
|
||||
- **Admin operations**: User listing/editing, admin-triggered member password reset emails, membership tier CRUD, manual payment recording, Square refunds, email template editing with escaped previews, SMTP2GO bounce management, profile-question management, and super-admin feature-flag reloads.
|
||||
- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits.
|
||||
- **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking.
|
||||
- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility.
|
||||
- **Volunteer and profile data**: Volunteer flag/level support, configurable member profile questions, conditional questions, admin-only answers, seeded aviation/volunteering questions, and data models for volunteer roles, assignments, schedules, and certificates.
|
||||
- **Email system**: SMTP2GO-backed email sending, default database templates, editable templates, welcome/password-reset/test emails, bounce webhooks, bounce stats, cleanup, and manual deactivation.
|
||||
- **Feature flags**: Backend feature-flag service with frontend context and admin status/reload controls.
|
||||
- **Testing**: Fast frontend Vitest unit tests and backend pytest unit tests wired into `restart.sh`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: FastAPI (Python 3.11)
|
||||
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS
|
||||
- **Database**: MySQL 8.0
|
||||
- **Authentication**: JWT tokens with OAuth2
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **ORM**: SQLAlchemy
|
||||
- **Migrations**: Alembic
|
||||
- **Payments**: Square Web Payments SDK and Square API
|
||||
- **Email**: SMTP2GO
|
||||
- **Tests**: Vitest and pytest
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -37,7 +45,11 @@ membership/
|
||||
│ │ │ │ ├── users.py # User management
|
||||
│ │ │ │ ├── tiers.py # Membership tiers
|
||||
│ │ │ │ ├── memberships.py # Membership management
|
||||
│ │ │ │ └── payments.py # Payment processing
|
||||
│ │ │ │ ├── payments.py # Payment processing
|
||||
│ │ │ │ ├── email.py # SMTP2GO email and bounces
|
||||
│ │ │ │ ├── email_templates.py
|
||||
│ │ │ │ ├── events.py # Events and RSVPs
|
||||
│ │ │ │ └── feature_flags.py
|
||||
│ │ │ └── dependencies.py # Auth dependencies
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── config.py # Configuration
|
||||
@@ -50,8 +62,13 @@ membership/
|
||||
│ │ └── main.py # Application entry point
|
||||
│ ├── Dockerfile
|
||||
│ └── requirements.txt
|
||||
├── database/
|
||||
│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Dashboard, payment, admin, profile components
|
||||
│ │ ├── contexts/ # Feature flag context
|
||||
│ │ ├── pages/ # Login, register, dashboard, policy pages
|
||||
│ │ ├── services/ # API clients
|
||||
│ │ └── utils/ # Tested frontend logic
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
@@ -95,6 +112,25 @@ membership/
|
||||
- API Documentation: http://localhost:8050/docs
|
||||
- TLS certs are generated automatically by the gateway container on first start
|
||||
|
||||
## Restart and Test Gate
|
||||
|
||||
`restart.sh` rebuilds images with cache, runs the fast frontend and backend unit tests, then restarts the stack only if tests pass:
|
||||
|
||||
```bash
|
||||
./restart.sh
|
||||
```
|
||||
|
||||
The current fast test suite covers:
|
||||
- frontend profile-question visibility and editability rules with Vitest
|
||||
- backend profile-question option parsing, answer normalization/deserialization, select validation, and volunteer flag normalization with pytest
|
||||
|
||||
You can also run them individually:
|
||||
|
||||
```bash
|
||||
docker compose run --rm frontend npm test
|
||||
docker compose run --rm backend pytest -q
|
||||
```
|
||||
|
||||
## Frontend Development vs Production
|
||||
|
||||
### Development Mode (Vite)
|
||||
@@ -191,6 +227,39 @@ docker compose --profile prod down
|
||||
- `PUT /api/v1/payments/{id}` - Update payment (admin)
|
||||
- `GET /api/v1/payments/` - List all payments (admin)
|
||||
- `POST /api/v1/payments/manual-payment` - Record manual payment (admin)
|
||||
- `GET /api/v1/payments/config/square` - Get frontend Square config
|
||||
- `POST /api/v1/payments/square/process` - Process Square card payment
|
||||
- `POST /api/v1/payments/square/refund` - Refund Square payment (admin)
|
||||
|
||||
### Profile Questions
|
||||
- `GET /api/v1/users/me/profile-questions` - List active questions with current answers
|
||||
- `PUT /api/v1/users/me/profile-answers` - Update editable answers
|
||||
- `GET /api/v1/users/admin/profile-questions` - List all profile questions (admin)
|
||||
- `POST /api/v1/users/admin/profile-questions` - Create profile question (admin)
|
||||
- `PUT /api/v1/users/admin/profile-questions/{id}` - Update profile question (admin)
|
||||
- `DELETE /api/v1/users/admin/profile-questions/{id}` - Deactivate profile question (admin)
|
||||
- `GET /api/v1/users/admin/users/{id}/profile-answers` - View user answers (admin)
|
||||
- `PUT /api/v1/users/admin/users/{id}/profile-answers` - Update user answers (admin)
|
||||
|
||||
### Events
|
||||
- `GET /api/v1/events/` - List events
|
||||
- `GET /api/v1/events/upcoming` - List upcoming events
|
||||
- `POST /api/v1/events/` - Create event (admin)
|
||||
- `PUT /api/v1/events/{id}` - Update event (admin)
|
||||
- `DELETE /api/v1/events/{id}` - Delete event (admin)
|
||||
- `GET /api/v1/events/{id}/rsvps` - List RSVPs (admin)
|
||||
- `POST /api/v1/events/{id}/rsvp` - Create or update current user's RSVP
|
||||
|
||||
### Email and Feature Flags
|
||||
- `POST /api/v1/email/test-email` - Send test email
|
||||
- `POST /api/v1/email/test-welcome-email` - Send test welcome email
|
||||
- `POST /api/v1/email/webhooks/smtp2go/bounce` - Receive SMTP2GO bounce webhook
|
||||
- `GET /api/v1/email/bounces` - List bounces
|
||||
- `GET /api/v1/email/bounces/stats` - Bounce statistics
|
||||
- `GET /api/v1/email-templates/` - List templates
|
||||
- `PUT /api/v1/email-templates/{template_key}` - Update template
|
||||
- `GET /api/v1/feature-flags/flags` - List flags
|
||||
- `POST /api/v1/feature-flags/flags/reload` - Reload flags (super admin)
|
||||
|
||||
## Docker Compose Commands
|
||||
|
||||
@@ -362,16 +431,13 @@ docker compose up -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
## Remaining Roadmap
|
||||
|
||||
- [ ] Implement Square payment integration
|
||||
- [ ] Add email notification system
|
||||
- [ ] Create event management endpoints
|
||||
- [ ] Add volunteer management features
|
||||
- [ ] Build frontend interface
|
||||
- [ ] Add file upload/management
|
||||
- [ ] Implement automated renewal reminders
|
||||
- [ ] Add member file upload/repository endpoints and UI
|
||||
- [ ] Add richer volunteer role, assignment, schedule, and certificate screens on top of the existing models
|
||||
- [ ] Implement automated renewal reminder batch jobs
|
||||
- [ ] Add reporting and analytics
|
||||
- [ ] Expand test coverage around authenticated API flows and payment/email service boundaries
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+15
-13
@@ -36,7 +36,7 @@
|
||||
- [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide
|
||||
- [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details
|
||||
- [x] Created `SQUARE_QUICKSTART.md` - Quick start guide
|
||||
- [x] Created `deploy-square.sh` - Deployment helper script
|
||||
- [x] Updated `restart.sh` - Build, fast tests, and restart helper
|
||||
|
||||
### Code Quality
|
||||
- [x] No Python syntax errors
|
||||
@@ -64,13 +64,15 @@ Before deploying, complete these steps:
|
||||
- [ ] Set SQUARE_ENVIRONMENT=sandbox
|
||||
|
||||
### 3. Deployment
|
||||
- [ ] Run `./deploy-square.sh` OR
|
||||
- [ ] Run `docker-compose down`
|
||||
- [ ] Run `docker-compose up -d --build`
|
||||
- [ ] Verify containers are running: `docker-compose ps`
|
||||
- [ ] Run `./restart.sh` OR
|
||||
- [ ] Run `docker compose build`
|
||||
- [ ] Run `docker compose run --rm frontend npm test`
|
||||
- [ ] Run `docker compose run --rm backend pytest -q`
|
||||
- [ ] Run `docker compose up -d`
|
||||
- [ ] Verify containers are running: `docker compose ps`
|
||||
|
||||
### 4. Testing
|
||||
- [ ] Access frontend at http://localhost:3000
|
||||
- [ ] Access frontend at http://localhost:8050 or HTTPS at https://localhost:8443
|
||||
- [ ] Login/register a user
|
||||
- [ ] Navigate to membership setup
|
||||
- [ ] Select a membership tier
|
||||
@@ -104,7 +106,7 @@ After deployment, run these commands to verify:
|
||||
|
||||
```bash
|
||||
# Check backend is running
|
||||
curl http://localhost:8000/api/v1/payments/config/square
|
||||
curl http://localhost:8050/api/v1/payments/config/square
|
||||
|
||||
# Expected output (with your actual IDs):
|
||||
# {
|
||||
@@ -114,10 +116,10 @@ curl http://localhost:8000/api/v1/payments/config/square
|
||||
# }
|
||||
|
||||
# Check frontend is running
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8050
|
||||
|
||||
# Check logs
|
||||
docker-compose logs backend | grep -i square
|
||||
docker compose logs backend | grep -i square
|
||||
```
|
||||
|
||||
## 📊 Testing Matrix
|
||||
@@ -135,13 +137,13 @@ docker-compose logs backend | grep -i square
|
||||
|
||||
```bash
|
||||
# Check Square SDK installed
|
||||
docker-compose exec backend pip list | grep square
|
||||
docker compose exec backend pip list | grep square
|
||||
|
||||
# Check configuration loaded
|
||||
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
|
||||
docker compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
|
||||
|
||||
# Check database has payments
|
||||
docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
|
||||
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
|
||||
|
||||
# Check frontend files
|
||||
ls -la frontend/src/components/SquarePayment.tsx
|
||||
@@ -151,7 +153,7 @@ ls -la frontend/src/components/SquarePayment.tsx
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Module not found: squareup" | Rebuild backend: `docker-compose build backend` |
|
||||
| "Module not found: squareup" | Rebuild backend: `docker compose build backend` |
|
||||
| "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers |
|
||||
| Square SDK not loading | Check browser console, verify script tag in index.html |
|
||||
| Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct |
|
||||
|
||||
@@ -193,6 +193,15 @@ The Square payment integration is complete, tested, and working in sandbox mode:
|
||||
- Users can retry failed payments
|
||||
- Cash payments still work with PENDING status for admin approval
|
||||
- All payment flows properly tested with Square sandbox test cards
|
||||
- `restart.sh` now runs the fast Vitest and pytest suites before restarting the stack
|
||||
|
||||
Fast verification commands:
|
||||
|
||||
```bash
|
||||
./restart.sh
|
||||
docker compose run --rm frontend npm test
|
||||
docker compose run --rm backend pytest -q
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -66,13 +66,14 @@ SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID
|
||||
|
||||
### 5. Restart the Application
|
||||
|
||||
After updating the environment variables, restart your Docker containers:
|
||||
After updating the environment variables, run the tested restart helper:
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
./restart.sh
|
||||
```
|
||||
|
||||
For a manual restart, run `docker compose build`, `docker compose run --rm frontend npm test`, `docker compose run --rm backend pytest -q`, and then `docker compose up -d`.
|
||||
|
||||
## Testing with Sandbox
|
||||
|
||||
Square provides test card numbers for sandbox testing:
|
||||
|
||||
@@ -38,19 +38,21 @@ SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id...
|
||||
Run the deployment script:
|
||||
|
||||
```bash
|
||||
./deploy-square.sh
|
||||
./restart.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
docker compose build
|
||||
docker compose run --rm frontend npm test
|
||||
docker compose run --rm backend pytest -q
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Step 4: Test It Out!
|
||||
|
||||
1. Open http://localhost:3000
|
||||
1. Open http://localhost:8050 or https://localhost:8443 for HTTPS Square testing
|
||||
2. Register/login
|
||||
3. Go to "Setup Membership"
|
||||
4. Select a tier
|
||||
@@ -78,7 +80,7 @@ docker-compose up -d --build
|
||||
- ✅ `.env.example` - UPDATED
|
||||
- ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
|
||||
- ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
|
||||
- ✅ `deploy-square.sh` - NEW (deployment helper)
|
||||
- ✅ `restart.sh` - build, fast tests, and restart helper
|
||||
|
||||
## 🔧 Key Features
|
||||
|
||||
@@ -118,7 +120,7 @@ User → Select Tier → Choose Payment Method
|
||||
|
||||
### Backend won't start?
|
||||
```bash
|
||||
docker-compose logs backend
|
||||
docker compose logs backend
|
||||
```
|
||||
Check for missing dependencies or configuration errors.
|
||||
|
||||
@@ -156,7 +158,7 @@ When ready for production payments:
|
||||
|
||||
1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions
|
||||
2. Review Square's documentation
|
||||
3. Check application logs: `docker-compose logs -f backend`
|
||||
3. Check application logs: `docker compose logs -f backend`
|
||||
4. Contact Square support for payment-specific issues
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Add volunteer level and dynamic profile questions
|
||||
|
||||
Revision ID: 2e8a0f9d4b31
|
||||
Revises: b583fd2cf202
|
||||
Create Date: 2026-05-04 17:50:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2e8a0f9d4b31'
|
||||
down_revision: Union[str, None] = 'b583fd2cf202'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('volunteer_level', sa.String(length=50), nullable=True))
|
||||
|
||||
op.create_table(
|
||||
'profile_questions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('key', sa.String(length=100), nullable=False),
|
||||
sa.Column('label', sa.String(length=255), nullable=False),
|
||||
sa.Column('help_text', sa.Text(), nullable=True),
|
||||
sa.Column('input_type', sa.String(length=30), nullable=False),
|
||||
sa.Column('placeholder', sa.String(length=255), nullable=True),
|
||||
sa.Column('options_json', sa.Text(), nullable=True),
|
||||
sa.Column('is_required', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('admin_only_edit', sa.Boolean(), nullable=False),
|
||||
sa.Column('display_order', sa.Integer(), nullable=False),
|
||||
sa.Column('depends_on_question_id', sa.Integer(), nullable=True),
|
||||
sa.Column('depends_on_value', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['depends_on_question_id'], ['profile_questions.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(op.f('ix_profile_questions_id'), 'profile_questions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_profile_questions_key'), 'profile_questions', ['key'], unique=True)
|
||||
|
||||
op.create_table(
|
||||
'user_profile_answers',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('question_id', sa.Integer(), nullable=False),
|
||||
sa.Column('value_text', sa.Text(), nullable=True),
|
||||
sa.Column('updated_by_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['question_id'], ['profile_questions.id']),
|
||||
sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'question_id', name='uq_user_profile_answer'),
|
||||
)
|
||||
op.create_index(op.f('ix_user_profile_answers_id'), 'user_profile_answers', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_user_profile_answers_question_id'), 'user_profile_answers', ['question_id'], unique=False)
|
||||
op.create_index(op.f('ix_user_profile_answers_user_id'), 'user_profile_answers', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_user_profile_answers_user_id'), table_name='user_profile_answers')
|
||||
op.drop_index(op.f('ix_user_profile_answers_question_id'), table_name='user_profile_answers')
|
||||
op.drop_index(op.f('ix_user_profile_answers_id'), table_name='user_profile_answers')
|
||||
op.drop_table('user_profile_answers')
|
||||
|
||||
op.drop_index(op.f('ix_profile_questions_key'), table_name='profile_questions')
|
||||
op.drop_index(op.f('ix_profile_questions_id'), table_name='profile_questions')
|
||||
op.drop_table('profile_questions')
|
||||
|
||||
op.drop_column('users', 'volunteer_level')
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags
|
||||
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags, esp
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -12,3 +12,4 @@ api_router.include_router(email.router, prefix="/email", tags=["email"])
|
||||
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
||||
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
||||
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
|
||||
api_router.include_router(esp.router, prefix="/esp", tags=["esp-rfid"])
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import List
|
||||
import uuid
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.datetime import utc_now
|
||||
from ...core.security import verify_password, get_password_hash, create_access_token
|
||||
from ...models.models import User, UserRole, PasswordResetToken
|
||||
from ...schemas import (
|
||||
@@ -85,7 +86,7 @@ async def login(
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
user.last_login = utc_now()
|
||||
db.commit()
|
||||
|
||||
# Create access token
|
||||
@@ -120,7 +121,7 @@ async def login_json(
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
user.last_login = utc_now()
|
||||
db.commit()
|
||||
|
||||
# Create access token
|
||||
@@ -149,12 +150,12 @@ async def forgot_password(
|
||||
db.query(PasswordResetToken).filter(
|
||||
PasswordResetToken.user_id == user.id,
|
||||
PasswordResetToken.used == False,
|
||||
PasswordResetToken.expires_at > datetime.utcnow()
|
||||
PasswordResetToken.expires_at > utc_now()
|
||||
).update({"used": True})
|
||||
|
||||
# Generate new reset token
|
||||
reset_token = str(uuid.uuid4())
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1) # Token expires in 1 hour
|
||||
expires_at = utc_now() + timedelta(hours=1) # Token expires in 1 hour
|
||||
|
||||
# Create password reset token
|
||||
db_token = PasswordResetToken(
|
||||
@@ -192,7 +193,7 @@ async def reset_password(
|
||||
reset_token = db.query(PasswordResetToken).filter(
|
||||
PasswordResetToken.token == request.token,
|
||||
PasswordResetToken.used == False,
|
||||
PasswordResetToken.expires_at > datetime.utcnow()
|
||||
PasswordResetToken.expires_at > utc_now()
|
||||
).first()
|
||||
|
||||
if not reset_token:
|
||||
@@ -212,7 +213,7 @@ async def reset_password(
|
||||
# Update password
|
||||
hashed_password = get_password_hash(request.new_password)
|
||||
user.hashed_password = hashed_password
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = utc_now()
|
||||
|
||||
# Mark token as used
|
||||
reset_token.used = True
|
||||
@@ -239,7 +240,7 @@ async def change_password(
|
||||
# Update password
|
||||
hashed_password = get_password_hash(request.new_password)
|
||||
current_user.hashed_password = hashed_password
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
current_user.updated_at = utc_now()
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from ...api.dependencies import get_admin_user
|
||||
from ...models.models import User
|
||||
from typing import Dict, Any, List
|
||||
from ...core.database import get_db
|
||||
from ...core.datetime import to_zulu_iso
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
@@ -95,7 +96,7 @@ async def get_bounce_list(
|
||||
"email": bounce.email,
|
||||
"bounce_type": bounce.bounce_type.value,
|
||||
"bounce_reason": bounce.bounce_reason,
|
||||
"bounce_date": bounce.bounce_date.isoformat(),
|
||||
"bounce_date": to_zulu_iso(bounce.bounce_date),
|
||||
"is_active": bounce.is_active,
|
||||
"smtp2go_message_id": bounce.smtp2go_message_id
|
||||
}
|
||||
@@ -132,7 +133,7 @@ async def get_bounce_history(
|
||||
"id": bounce.id,
|
||||
"bounce_type": bounce.bounce_type.value,
|
||||
"bounce_reason": bounce.bounce_reason,
|
||||
"bounce_date": bounce.bounce_date.isoformat(),
|
||||
"bounce_date": to_zulu_iso(bounce.bounce_date),
|
||||
"is_active": bounce.is_active,
|
||||
"smtp2go_message_id": bounce.smtp2go_message_id
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.datetime import utc_now
|
||||
from ...models.models import Event, EventRSVP, User, EventStatus
|
||||
from ...schemas import (
|
||||
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
|
||||
@@ -13,6 +13,10 @@ from ...api.dependencies import get_current_active_user, get_admin_user
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _utc_time_string(value) -> str:
|
||||
return value.strftime("%H:%M")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[EventResponse])
|
||||
async def get_events(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -34,9 +38,9 @@ async def get_upcoming_events(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get upcoming events"""
|
||||
now = datetime.now()
|
||||
now = utc_now()
|
||||
events = db.query(Event).filter(
|
||||
Event.event_date >= now.date(),
|
||||
Event.event_date >= now,
|
||||
Event.status == EventStatus.PUBLISHED
|
||||
).order_by(Event.event_date).all()
|
||||
return events
|
||||
@@ -50,7 +54,7 @@ async def create_event(
|
||||
):
|
||||
"""Create a new event (admin only)"""
|
||||
# Validate event date is in the future
|
||||
if event_data.event_date < datetime.now():
|
||||
if event_data.event_date < utc_now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Event date must be in the future"
|
||||
@@ -60,7 +64,7 @@ async def create_event(
|
||||
title=event_data.title,
|
||||
description=event_data.description,
|
||||
event_date=event_data.event_date,
|
||||
event_time=event_data.event_time,
|
||||
event_time=_utc_time_string(event_data.event_date),
|
||||
location=event_data.location,
|
||||
max_attendees=event_data.max_attendees,
|
||||
status=EventStatus.DRAFT,
|
||||
@@ -89,10 +93,14 @@ async def update_event(
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for field, value in event_data.dict(exclude_unset=True).items():
|
||||
update_data = event_data.model_dump(exclude_unset=True)
|
||||
if "event_date" in update_data:
|
||||
update_data["event_time"] = _utc_time_string(update_data["event_date"])
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(event, field, value)
|
||||
|
||||
event.updated_at = datetime.now()
|
||||
event.updated_at = utc_now()
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
@@ -167,7 +175,7 @@ async def create_or_update_rsvp(
|
||||
existing_rsvp.status = rsvp_data.status
|
||||
if rsvp_data.notes is not None:
|
||||
existing_rsvp.notes = rsvp_data.notes
|
||||
existing_rsvp.updated_at = datetime.now()
|
||||
existing_rsvp.updated_at = utc_now()
|
||||
db.commit()
|
||||
db.refresh(existing_rsvp)
|
||||
return existing_rsvp
|
||||
@@ -204,4 +212,4 @@ async def get_my_rsvps(
|
||||
):
|
||||
"""Get current user's RSVPs"""
|
||||
rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all()
|
||||
return rsvps
|
||||
return rsvps
|
||||
|
||||
@@ -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"}
|
||||
return {"message": "Feature flags reloaded successfully"}
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.datetime import unix_ms_utc, utc_now
|
||||
from ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier
|
||||
from ...schemas import (
|
||||
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse,
|
||||
@@ -121,7 +122,7 @@ async def update_payment(
|
||||
|
||||
# If marking as completed, set payment_date if not already set
|
||||
if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date:
|
||||
update_data["payment_date"] = datetime.utcnow()
|
||||
update_data["payment_date"] = utc_now()
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(payment, field, value)
|
||||
@@ -182,7 +183,7 @@ async def process_square_payment(
|
||||
)
|
||||
|
||||
# Create a reference ID for tracking
|
||||
reference_id = f"user_{current_user.id}_tier_{tier.id}_{datetime.utcnow().timestamp()}"
|
||||
reference_id = f"user_{current_user.id}_tier_{tier.id}_{unix_ms_utc(utc_now())}"
|
||||
|
||||
# Process payment with Square
|
||||
square_result = await square_service.create_payment(
|
||||
@@ -204,7 +205,7 @@ async def process_square_payment(
|
||||
# Payment succeeded - create membership and payment records in a transaction
|
||||
try:
|
||||
# Calculate membership dates
|
||||
start_date = datetime.utcnow().date()
|
||||
start_date = utc_now().date()
|
||||
end_date = start_date + relativedelta(years=1)
|
||||
|
||||
# Create membership with ACTIVE status
|
||||
@@ -226,7 +227,7 @@ async def process_square_payment(
|
||||
payment_method=PaymentMethod.SQUARE,
|
||||
status=PaymentStatus.COMPLETED,
|
||||
transaction_id=square_result.get('payment_id'),
|
||||
payment_date=datetime.utcnow(),
|
||||
payment_date=utc_now(),
|
||||
notes=payment_request.note
|
||||
)
|
||||
db.add(payment)
|
||||
@@ -389,7 +390,7 @@ async def record_manual_payment(
|
||||
payment_method=payment_data.payment_method,
|
||||
notes=payment_data.notes,
|
||||
status=PaymentStatus.COMPLETED,
|
||||
payment_date=datetime.utcnow()
|
||||
payment_date=utc_now()
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
|
||||
+642
-17
@@ -1,16 +1,189 @@
|
||||
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 ...core.datetime import utc_now
|
||||
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 +200,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 +329,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 +634,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 > utc_now()
|
||||
).update({"used": True})
|
||||
|
||||
reset_token = str(uuid.uuid4())
|
||||
expires_at = utc_now() + 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 +738,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"}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -47,9 +52,11 @@ class Settings(BaseSettings):
|
||||
UPLOAD_DIR: str = "/app/uploads"
|
||||
MAX_UPLOAD_SIZE: int = 10485760 # 10MB
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(PROJECT_ROOT / ".env", BACKEND_ROOT / ".env", ".env"),
|
||||
case_sensitive=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from .config import settings
|
||||
|
||||
engine = create_engine(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from .config import settings
|
||||
from .datetime import utc_now
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
MACHINE_TOKEN_PREFIX = "sha256$"
|
||||
|
||||
|
||||
def create_access_token(
|
||||
@@ -12,9 +16,9 @@ def create_access_token(
|
||||
) -> str:
|
||||
"""Create JWT access token"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
expire = utc_now() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
expire = utc_now() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
@@ -33,6 +37,26 @@ def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def get_machine_token_hash(token: str) -> str:
|
||||
"""Hash a machine token for fast constant-time verification."""
|
||||
digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
return f"{MACHINE_TOKEN_PREFIX}{digest}"
|
||||
|
||||
|
||||
def verify_machine_token(token: str, stored_hash: str) -> bool:
|
||||
"""Verify a machine token, supporting legacy bcrypt hashes during migration."""
|
||||
if not stored_hash:
|
||||
return False
|
||||
if stored_hash.startswith(MACHINE_TOKEN_PREFIX):
|
||||
expected_hash = get_machine_token_hash(token)
|
||||
return hmac.compare_digest(expected_hash, stored_hash)
|
||||
return verify_password(token, stored_hash)
|
||||
|
||||
|
||||
def is_machine_token_hash(stored_hash: str | None) -> bool:
|
||||
return bool(stored_hash and stored_hash.startswith(MACHINE_TOKEN_PREFIX))
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[str]:
|
||||
"""Decode JWT token and return subject"""
|
||||
try:
|
||||
|
||||
+36
-2
@@ -1,13 +1,30 @@
|
||||
import asyncio
|
||||
import time
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import Request
|
||||
from contextlib import asynccontextmanager
|
||||
from .core.config import settings
|
||||
from .api.v1 import api_router
|
||||
from .core.database import get_db
|
||||
from .core.database import SessionLocal, get_db
|
||||
from .core.init_db import init_default_data
|
||||
from .services.attendance_service import close_stale_attendance_sessions
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
async def close_stale_attendance_loop():
|
||||
"""Periodically close forgotten RFID check-ins after midnight."""
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
close_stale_attendance_sessions(db)
|
||||
except Exception as exc:
|
||||
print(f"Failed to close stale attendance sessions: {exc}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Handle startup and shutdown events"""
|
||||
@@ -15,13 +32,20 @@ async def lifespan(app: FastAPI):
|
||||
db: Session = next(get_db())
|
||||
try:
|
||||
init_default_data(db)
|
||||
close_stale_attendance_sessions(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
attendance_task = asyncio.create_task(close_stale_attendance_loop())
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown (if needed)
|
||||
pass
|
||||
attendance_task.cancel()
|
||||
try:
|
||||
await attendance_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -40,6 +64,16 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_request_timing_headers(request: Request, call_next):
|
||||
started_at = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
response.headers["X-Process-Time-Ms"] = f"{elapsed_ms:.1f}"
|
||||
response.headers["Server-Timing"] = f"app;dur={elapsed_ms:.1f}"
|
||||
return response
|
||||
|
||||
# Include API router
|
||||
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+221
-30
@@ -1,11 +1,11 @@
|
||||
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
|
||||
import enum
|
||||
from ..core.database import Base
|
||||
from ..core.datetime import utc_now
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
@@ -49,6 +49,37 @@ class RSVPStatus(str, enum.Enum):
|
||||
MAYBE = "maybe"
|
||||
|
||||
|
||||
class EspReaderType(str, enum.Enum):
|
||||
CHECKIN_CHECKOUT = "checkin_checkout"
|
||||
|
||||
|
||||
class EspReaderProvisioningStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
PROVISIONED = "provisioned"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class EspTapAction(str, enum.Enum):
|
||||
CHECK_IN = "check_in"
|
||||
CHECK_OUT = "check_out"
|
||||
DENIED = "denied"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class AttendanceCheckoutSource(str, enum.Enum):
|
||||
USER = "user"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class RfidWriteJobStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
CLAIMED = "claimed"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -60,9 +91,10 @@ 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)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
@@ -71,6 +103,56 @@ 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"
|
||||
)
|
||||
rfid_cards = relationship("RfidCard", back_populates="user")
|
||||
attendance_sessions = relationship("AttendanceSession", back_populates="user")
|
||||
|
||||
|
||||
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=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, 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=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, 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):
|
||||
@@ -82,8 +164,8 @@ class MembershipTier(Base):
|
||||
annual_fee = Column(Float, nullable=False)
|
||||
benefits = Column(Text, 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)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
memberships = relationship("Membership", back_populates="tier")
|
||||
@@ -99,8 +181,8 @@ class Membership(Base):
|
||||
start_date = Column(Date, nullable=False)
|
||||
end_date = Column(Date, nullable=False)
|
||||
auto_renew = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="memberships")
|
||||
@@ -120,8 +202,8 @@ class Payment(Base):
|
||||
transaction_id = Column(String(255), nullable=True)
|
||||
payment_date = Column(DateTime, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="payments")
|
||||
@@ -140,8 +222,8 @@ class Event(Base):
|
||||
max_attendees = Column(Integer, nullable=True)
|
||||
status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
|
||||
@@ -156,14 +238,123 @@ class EventRSVP(Base):
|
||||
status = Column(SQLEnum(RSVPStatus, values_callable=lambda x: [e.value for e in x]), default=RSVPStatus.PENDING, nullable=False)
|
||||
attended = Column(Boolean, default=False, nullable=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
event = relationship("Event", back_populates="rsvps")
|
||||
user = relationship("User", back_populates="event_rsvps")
|
||||
|
||||
|
||||
class EspReader(Base):
|
||||
__tablename__ = "esp_readers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
device_id = Column(String(100), unique=True, index=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
location = Column(String(255), nullable=True)
|
||||
reader_type = Column(SQLEnum(EspReaderType, values_callable=lambda x: [e.value for e in x]), default=EspReaderType.CHECKIN_CHECKOUT, nullable=False)
|
||||
provisioning_status = Column(SQLEnum(EspReaderProvisioningStatus, values_callable=lambda x: [e.value for e in x]), default=EspReaderProvisioningStatus.PENDING, nullable=False)
|
||||
api_key_hash = Column(String(255), nullable=True)
|
||||
pending_api_key = Column(String(255), nullable=True)
|
||||
registration_token_hash = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
can_write_cards = Column(Boolean, default=False, nullable=False)
|
||||
firmware_version = Column(String(100), nullable=True)
|
||||
last_seen_at = Column(DateTime, nullable=True)
|
||||
approved_at = Column(DateTime, nullable=True)
|
||||
provisioned_at = Column(DateTime, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
taps = relationship("RfidTap", back_populates="reader")
|
||||
attendance_sessions = relationship("AttendanceSession", back_populates="reader")
|
||||
write_jobs = relationship("RfidCardWriteJob", back_populates="reader")
|
||||
|
||||
|
||||
class RfidCard(Base):
|
||||
__tablename__ = "rfid_cards"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uid = Column(String(100), unique=True, index=True, nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
label = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="rfid_cards")
|
||||
taps = relationship("RfidTap", back_populates="card")
|
||||
|
||||
|
||||
class RfidCardWriteJob(Base):
|
||||
__tablename__ = "rfid_card_write_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True)
|
||||
label = Column(String(255), nullable=False)
|
||||
status = Column(SQLEnum(RfidWriteJobStatus, values_callable=lambda x: [e.value for e in x]), default=RfidWriteJobStatus.PENDING, nullable=False, index=True)
|
||||
requested_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
card_uid = Column(String(100), nullable=True, index=True)
|
||||
write_payload = Column(Text, nullable=True)
|
||||
claimed_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
error_message = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
reader = relationship("EspReader", back_populates="write_jobs")
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
requested_by_user = relationship("User", foreign_keys=[requested_by_user_id])
|
||||
card = relationship("RfidCard")
|
||||
|
||||
|
||||
class RfidTap(Base):
|
||||
__tablename__ = "rfid_taps"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True)
|
||||
card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
card_uid = Column(String(100), nullable=False, index=True)
|
||||
action = Column(SQLEnum(EspTapAction, values_callable=lambda x: [e.value for e in x]), default=EspTapAction.UNKNOWN, nullable=False)
|
||||
accepted = Column(Boolean, default=False, nullable=False)
|
||||
message = Column(String(255), nullable=True)
|
||||
raw_payload = Column(Text, nullable=True)
|
||||
tapped_at = Column(DateTime, default=utc_now, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
|
||||
reader = relationship("EspReader", back_populates="taps")
|
||||
card = relationship("RfidCard", back_populates="taps")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class AttendanceSession(Base):
|
||||
__tablename__ = "attendance_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True)
|
||||
check_in_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=False)
|
||||
check_out_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=True)
|
||||
checked_in_at = Column(DateTime, nullable=False, index=True)
|
||||
checked_out_at = Column(DateTime, nullable=True, index=True)
|
||||
checkout_source = Column(SQLEnum(AttendanceCheckoutSource, values_callable=lambda x: [e.value for e in x]), nullable=True)
|
||||
system_flag_reason = Column(String(255), nullable=True)
|
||||
duration_seconds = Column(Integer, nullable=True)
|
||||
is_open = Column(Boolean, default=True, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="attendance_sessions")
|
||||
reader = relationship("EspReader", back_populates="attendance_sessions")
|
||||
check_in_tap = relationship("RfidTap", foreign_keys=[check_in_tap_id])
|
||||
check_out_tap = relationship("RfidTap", foreign_keys=[check_out_tap_id])
|
||||
|
||||
|
||||
class VolunteerRole(Base):
|
||||
__tablename__ = "volunteer_roles"
|
||||
|
||||
@@ -171,8 +362,8 @@ class VolunteerRole(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, 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)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan")
|
||||
@@ -187,8 +378,8 @@ class VolunteerAssignment(Base):
|
||||
assigned_date = Column(Date, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="volunteer_assignments")
|
||||
@@ -207,8 +398,8 @@ class VolunteerSchedule(Base):
|
||||
location = Column(String(255), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
completed = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
assignment = relationship("VolunteerAssignment", back_populates="schedules")
|
||||
@@ -226,8 +417,8 @@ class Certificate(Base):
|
||||
certificate_number = Column(String(100), nullable=True)
|
||||
file_path = Column(String(500), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="certificates")
|
||||
@@ -245,8 +436,8 @@ class File(Base):
|
||||
min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
@@ -259,7 +450,7 @@ class Notification(Base):
|
||||
email_sent = Column(Boolean, default=False, nullable=False)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
@@ -270,7 +461,7 @@ class PasswordResetToken(Base):
|
||||
token = Column(String(255), unique=True, nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="password_reset_tokens")
|
||||
@@ -287,8 +478,8 @@ class EmailTemplate(Base):
|
||||
text_body = Column(Text, nullable=True)
|
||||
variables = Column(Text, nullable=True) # JSON string of available variables
|
||||
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)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
|
||||
class BounceType(str, enum.Enum):
|
||||
@@ -308,5 +499,5 @@ class EmailBounce(Base):
|
||||
smtp2go_message_id = Column(String(255), nullable=True, index=True)
|
||||
bounce_date = Column(DateTime, nullable=False)
|
||||
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)
|
||||
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
@@ -37,6 +37,36 @@ from .schemas import (
|
||||
EventRSVPBase,
|
||||
EventRSVPUpdate,
|
||||
EventRSVPResponse,
|
||||
QuestionOption,
|
||||
ProfileQuestionCreate,
|
||||
ProfileQuestionUpdate,
|
||||
ProfileQuestionResponse,
|
||||
ProfileQuestionForUser,
|
||||
ProfileAnswerUpdate,
|
||||
ProfileAnswersUpdateRequest,
|
||||
EspReaderCreate,
|
||||
EspReaderUpdate,
|
||||
EspReaderResponse,
|
||||
EspReaderCreateResponse,
|
||||
EspReaderRegistrationRequest,
|
||||
EspReaderRegistrationResponse,
|
||||
EspReaderProvisioningResponse,
|
||||
RfidCardCreate,
|
||||
RfidCardUpdate,
|
||||
RfidCardResponse,
|
||||
RfidTapRequest,
|
||||
RfidTapResponse,
|
||||
RfidWriteJobCreate,
|
||||
RfidWriteJobCompleteRequest,
|
||||
RfidWriteJobResponse,
|
||||
EspTimeResponse,
|
||||
EspHeartbeatRequest,
|
||||
EspHeartbeatResponse,
|
||||
EspDashboardLoginResponse,
|
||||
RfidTapAdminResponse,
|
||||
AttendanceSessionResponse,
|
||||
StaleSessionCloseRequest,
|
||||
StaleSessionCloseResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -78,4 +108,34 @@ __all__ = [
|
||||
"EventRSVPBase",
|
||||
"EventRSVPUpdate",
|
||||
"EventRSVPResponse",
|
||||
"QuestionOption",
|
||||
"ProfileQuestionCreate",
|
||||
"ProfileQuestionUpdate",
|
||||
"ProfileQuestionResponse",
|
||||
"ProfileQuestionForUser",
|
||||
"ProfileAnswerUpdate",
|
||||
"ProfileAnswersUpdateRequest",
|
||||
"EspReaderCreate",
|
||||
"EspReaderUpdate",
|
||||
"EspReaderResponse",
|
||||
"EspReaderCreateResponse",
|
||||
"EspReaderRegistrationRequest",
|
||||
"EspReaderRegistrationResponse",
|
||||
"EspReaderProvisioningResponse",
|
||||
"RfidCardCreate",
|
||||
"RfidCardUpdate",
|
||||
"RfidCardResponse",
|
||||
"RfidTapRequest",
|
||||
"RfidTapResponse",
|
||||
"RfidWriteJobCreate",
|
||||
"RfidWriteJobCompleteRequest",
|
||||
"RfidWriteJobResponse",
|
||||
"EspTimeResponse",
|
||||
"EspHeartbeatRequest",
|
||||
"EspHeartbeatResponse",
|
||||
"EspDashboardLoginResponse",
|
||||
"RfidTapAdminResponse",
|
||||
"AttendanceSessionResponse",
|
||||
"StaleSessionCloseRequest",
|
||||
"StaleSessionCloseResponse",
|
||||
]
|
||||
|
||||
+357
-28
@@ -1,11 +1,43 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_serializer, field_validator
|
||||
from typing import Optional, Literal, Any
|
||||
from datetime import datetime, date
|
||||
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
|
||||
from ..core.datetime import to_utc_naive, to_zulu_iso
|
||||
from ..models.models import (
|
||||
UserRole,
|
||||
MembershipStatus,
|
||||
PaymentStatus,
|
||||
PaymentMethod,
|
||||
EspReaderProvisioningStatus,
|
||||
EspReaderType,
|
||||
EspTapAction,
|
||||
RfidWriteJobStatus,
|
||||
)
|
||||
|
||||
|
||||
class UTCBaseModel(BaseModel):
|
||||
@field_validator("*", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def normalize_datetime_inputs(cls, value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return to_utc_naive(value)
|
||||
return value
|
||||
|
||||
@field_validator("*", mode="after", check_fields=False)
|
||||
@classmethod
|
||||
def normalize_parsed_datetimes(cls, value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return to_utc_naive(value)
|
||||
return value
|
||||
|
||||
@field_serializer("*", when_used="json", check_fields=False)
|
||||
def serialize_datetime_outputs(self, value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return to_zulu_iso(value)
|
||||
return value
|
||||
|
||||
|
||||
# User Schemas
|
||||
class UserBase(BaseModel):
|
||||
class UserBase(UTCBaseModel):
|
||||
email: EmailStr
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
@@ -17,13 +49,14 @@ class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
class UserUpdate(UTCBaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
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 +64,7 @@ class UserResponse(UserBase):
|
||||
|
||||
id: int
|
||||
role: UserRole
|
||||
volunteer_level: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
@@ -41,37 +75,37 @@ class UserInDB(UserResponse):
|
||||
|
||||
|
||||
# Authentication Schemas
|
||||
class Token(BaseModel):
|
||||
class Token(UTCBaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
class TokenData(UTCBaseModel):
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
class LoginRequest(UTCBaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
# Password Reset Schemas
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
class ForgotPasswordRequest(UTCBaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
class ResetPasswordRequest(UTCBaseModel):
|
||||
token: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
class ChangePasswordRequest(UTCBaseModel):
|
||||
current_password: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
# Membership Tier Schemas
|
||||
class MembershipTierBase(BaseModel):
|
||||
class MembershipTierBase(UTCBaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
annual_fee: float = Field(..., ge=0)
|
||||
@@ -82,7 +116,7 @@ class MembershipTierCreate(MembershipTierBase):
|
||||
pass
|
||||
|
||||
|
||||
class MembershipTierUpdate(BaseModel):
|
||||
class MembershipTierUpdate(UTCBaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
annual_fee: Optional[float] = Field(None, ge=0)
|
||||
@@ -99,7 +133,7 @@ class MembershipTierResponse(MembershipTierBase):
|
||||
|
||||
|
||||
# Membership Schemas
|
||||
class MembershipBase(BaseModel):
|
||||
class MembershipBase(UTCBaseModel):
|
||||
tier_id: int
|
||||
auto_renew: bool = False
|
||||
|
||||
@@ -109,14 +143,14 @@ class MembershipCreate(MembershipBase):
|
||||
end_date: date
|
||||
|
||||
|
||||
class MembershipUpdate(BaseModel):
|
||||
class MembershipUpdate(UTCBaseModel):
|
||||
tier_id: Optional[int] = None
|
||||
status: Optional[MembershipStatus] = None
|
||||
end_date: Optional[date] = None
|
||||
auto_renew: Optional[bool] = None
|
||||
|
||||
|
||||
class MembershipResponse(BaseModel):
|
||||
class MembershipResponse(UTCBaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
@@ -131,7 +165,7 @@ class MembershipResponse(BaseModel):
|
||||
|
||||
|
||||
# Payment Schemas
|
||||
class PaymentBase(BaseModel):
|
||||
class PaymentBase(UTCBaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
payment_method: PaymentMethod
|
||||
notes: Optional[str] = None
|
||||
@@ -141,14 +175,14 @@ class PaymentCreate(PaymentBase):
|
||||
membership_id: Optional[int] = None
|
||||
|
||||
|
||||
class PaymentUpdate(BaseModel):
|
||||
class PaymentUpdate(UTCBaseModel):
|
||||
status: Optional[PaymentStatus] = None
|
||||
transaction_id: Optional[str] = None
|
||||
payment_date: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PaymentResponse(BaseModel):
|
||||
class PaymentResponse(UTCBaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
@@ -164,7 +198,7 @@ class PaymentResponse(BaseModel):
|
||||
|
||||
|
||||
# Square Payment Schemas
|
||||
class SquarePaymentRequest(BaseModel):
|
||||
class SquarePaymentRequest(UTCBaseModel):
|
||||
"""Request schema for Square payment processing"""
|
||||
source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK")
|
||||
tier_id: int = Field(..., description="Membership tier ID to create membership for")
|
||||
@@ -174,7 +208,7 @@ class SquarePaymentRequest(BaseModel):
|
||||
billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS")
|
||||
|
||||
|
||||
class SquarePaymentResponse(BaseModel):
|
||||
class SquarePaymentResponse(UTCBaseModel):
|
||||
"""Response schema for Square payment"""
|
||||
success: bool
|
||||
payment_id: Optional[str] = None
|
||||
@@ -187,7 +221,7 @@ class SquarePaymentResponse(BaseModel):
|
||||
membership_id: Optional[int] = Field(None, description="Created membership ID")
|
||||
|
||||
|
||||
class SquareRefundRequest(BaseModel):
|
||||
class SquareRefundRequest(UTCBaseModel):
|
||||
"""Request schema for Square payment refund"""
|
||||
payment_id: int = Field(..., description="Database payment ID")
|
||||
amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)")
|
||||
@@ -195,13 +229,13 @@ class SquareRefundRequest(BaseModel):
|
||||
|
||||
|
||||
# Message Response
|
||||
class MessageResponse(BaseModel):
|
||||
class MessageResponse(UTCBaseModel):
|
||||
message: str
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# Email Template Schemas
|
||||
class EmailTemplateBase(BaseModel):
|
||||
class EmailTemplateBase(UTCBaseModel):
|
||||
template_key: str
|
||||
name: str
|
||||
subject: str
|
||||
@@ -214,7 +248,7 @@ class EmailTemplateCreate(EmailTemplateBase):
|
||||
pass
|
||||
|
||||
|
||||
class EmailTemplateUpdate(BaseModel):
|
||||
class EmailTemplateUpdate(UTCBaseModel):
|
||||
name: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
html_body: Optional[str] = None
|
||||
@@ -233,7 +267,7 @@ class EmailTemplateResponse(EmailTemplateBase):
|
||||
|
||||
|
||||
# Event Schemas
|
||||
class EventBase(BaseModel):
|
||||
class EventBase(UTCBaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
event_date: datetime
|
||||
@@ -246,7 +280,7 @@ class EventCreate(EventBase):
|
||||
pass
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
class EventUpdate(UTCBaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
event_date: Optional[datetime] = None
|
||||
@@ -267,7 +301,7 @@ class EventResponse(EventBase):
|
||||
|
||||
|
||||
# Event RSVP Schemas
|
||||
class EventRSVPBase(BaseModel):
|
||||
class EventRSVPBase(UTCBaseModel):
|
||||
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
|
||||
notes: Optional[str] = None
|
||||
|
||||
@@ -285,3 +319,298 @@ class EventRSVPResponse(EventRSVPBase):
|
||||
attended: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Profile Question Schemas
|
||||
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
|
||||
|
||||
|
||||
class QuestionOption(UTCBaseModel):
|
||||
label: str = Field(..., min_length=1, max_length=100)
|
||||
value: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ProfileQuestionBase(UTCBaseModel):
|
||||
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(UTCBaseModel):
|
||||
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(UTCBaseModel):
|
||||
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(UTCBaseModel):
|
||||
question_id: int
|
||||
value: Optional[Any] = None
|
||||
|
||||
|
||||
class ProfileAnswersUpdateRequest(UTCBaseModel):
|
||||
answers: list[ProfileAnswerUpdate]
|
||||
|
||||
|
||||
# ESP RFID Reader Schemas
|
||||
class EspReaderBase(UTCBaseModel):
|
||||
device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$")
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
can_write_cards: bool = False
|
||||
firmware_version: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class EspReaderCreate(EspReaderBase):
|
||||
api_key: Optional[str] = Field(None, min_length=16, max_length=255)
|
||||
|
||||
|
||||
class EspReaderUpdate(UTCBaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
reader_type: Optional[EspReaderType] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
can_write_cards: Optional[bool] = None
|
||||
rotate_api_key: bool = False
|
||||
|
||||
|
||||
class EspReaderResponse(EspReaderBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
provisioning_status: EspReaderProvisioningStatus
|
||||
last_seen_at: Optional[datetime] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
provisioned_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EspReaderCreateResponse(EspReaderResponse):
|
||||
api_key: str
|
||||
|
||||
|
||||
class EspReaderRegistrationRequest(UTCBaseModel):
|
||||
device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$")
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT
|
||||
can_write_cards: bool = False
|
||||
firmware_version: Optional[str] = Field(None, max_length=100)
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class EspReaderRegistrationResponse(UTCBaseModel):
|
||||
device_id: str
|
||||
provisioning_status: EspReaderProvisioningStatus
|
||||
registration_token: str
|
||||
message: str
|
||||
poll_interval_seconds: int = 5
|
||||
|
||||
|
||||
class EspReaderProvisioningResponse(UTCBaseModel):
|
||||
device_id: str
|
||||
provisioning_status: EspReaderProvisioningStatus
|
||||
message: str
|
||||
api_key: Optional[str] = None
|
||||
apiKey: Optional[str] = None
|
||||
poll_interval_seconds: int = 5
|
||||
|
||||
|
||||
class RfidCardBase(UTCBaseModel):
|
||||
uid: str = Field(..., min_length=2, max_length=100)
|
||||
user_id: Optional[int] = None
|
||||
label: Optional[str] = Field(None, max_length=255)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class RfidCardCreate(RfidCardBase):
|
||||
pass
|
||||
|
||||
|
||||
class RfidCardUpdate(UTCBaseModel):
|
||||
user_id: Optional[int] = None
|
||||
label: Optional[str] = Field(None, max_length=255)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class RfidCardResponse(RfidCardBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RfidTapRequest(UTCBaseModel):
|
||||
card_uid: str = Field(..., min_length=2, max_length=100)
|
||||
tapped_at: Optional[datetime] = None
|
||||
reader_type: Optional[EspReaderType] = None
|
||||
|
||||
|
||||
class RfidTapResponse(UTCBaseModel):
|
||||
accepted: bool
|
||||
action: EspTapAction
|
||||
message: str
|
||||
server_time_utc: datetime
|
||||
tap_id: int
|
||||
session_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
user_name: Optional[str] = None
|
||||
checked_in_at: Optional[datetime] = None
|
||||
checked_out_at: Optional[datetime] = None
|
||||
duration_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class RfidWriteJobCreate(UTCBaseModel):
|
||||
reader_id: int
|
||||
user_id: int
|
||||
label: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RfidWriteJobCompleteRequest(UTCBaseModel):
|
||||
card_uid: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
success: bool
|
||||
error_message: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class RfidWriteJobResponse(UTCBaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
reader_id: int
|
||||
user_id: int
|
||||
card_id: Optional[int] = None
|
||||
label: str
|
||||
status: RfidWriteJobStatus
|
||||
requested_by_user_id: int
|
||||
card_uid: Optional[str] = None
|
||||
write_payload: Optional[str] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EspTimeResponse(UTCBaseModel):
|
||||
server_time_utc: datetime
|
||||
unix_ms: int
|
||||
poll_interval_seconds: int = 3
|
||||
|
||||
|
||||
class EspHeartbeatRequest(UTCBaseModel):
|
||||
mode: str = Field(..., max_length=50)
|
||||
message: Optional[str] = Field(None, max_length=255)
|
||||
wifi_rssi: Optional[int] = None
|
||||
free_heap: Optional[int] = None
|
||||
firmware_version: Optional[str] = Field(None, max_length=100)
|
||||
active_write_job_id: Optional[int] = None
|
||||
|
||||
|
||||
class EspHeartbeatResponse(UTCBaseModel):
|
||||
ok: bool
|
||||
server_time_utc: datetime
|
||||
unix_ms: int
|
||||
heartbeat_interval_seconds: int = 10
|
||||
time_poll_interval_seconds: int = 3
|
||||
write_job_poll_interval_seconds: int = 3
|
||||
|
||||
|
||||
class EspDashboardLoginResponse(UTCBaseModel):
|
||||
valid: bool
|
||||
user_id: Optional[int] = None
|
||||
role: Optional[UserRole] = None
|
||||
user_name: Optional[str] = None
|
||||
|
||||
|
||||
class RfidTapAdminResponse(UTCBaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
reader_id: int
|
||||
card_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
card_uid: str
|
||||
action: EspTapAction
|
||||
accepted: bool
|
||||
message: Optional[str] = None
|
||||
tapped_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AttendanceSessionResponse(UTCBaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
reader_id: int
|
||||
check_in_tap_id: int
|
||||
check_out_tap_id: Optional[int] = None
|
||||
checked_in_at: datetime
|
||||
checked_out_at: Optional[datetime] = None
|
||||
checkout_source: Optional[str] = None
|
||||
system_flag_reason: Optional[str] = None
|
||||
duration_seconds: Optional[int] = None
|
||||
is_open: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StaleSessionCloseRequest(UTCBaseModel):
|
||||
cutoff_date: Optional[date] = None
|
||||
checkout_hour: int = Field(17, ge=0, le=23)
|
||||
|
||||
|
||||
class StaleSessionCloseResponse(UTCBaseModel):
|
||||
closed_count: int
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from ..core.datetime import to_utc_naive, utc_now
|
||||
from ..models.models import EmailBounce, BounceType
|
||||
from ..core.database import get_db
|
||||
|
||||
@@ -38,7 +39,9 @@ class BounceService:
|
||||
db = next(get_db())
|
||||
|
||||
if bounce_date is None:
|
||||
bounce_date = datetime.utcnow()
|
||||
bounce_date = utc_now()
|
||||
else:
|
||||
bounce_date = to_utc_naive(bounce_date)
|
||||
|
||||
# Check if bounce already exists for this email and type
|
||||
existing_bounce = db.query(EmailBounce).filter(
|
||||
@@ -54,7 +57,7 @@ class BounceService:
|
||||
if smtp2go_message_id:
|
||||
existing_bounce.smtp2go_message_id = smtp2go_message_id
|
||||
existing_bounce.bounce_date = bounce_date
|
||||
existing_bounce.updated_at = datetime.utcnow()
|
||||
existing_bounce.updated_at = utc_now()
|
||||
db.commit()
|
||||
db.refresh(existing_bounce)
|
||||
return existing_bounce
|
||||
@@ -130,7 +133,7 @@ class BounceService:
|
||||
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
|
||||
if bounce:
|
||||
bounce.is_active = False
|
||||
bounce.updated_at = datetime.utcnow()
|
||||
bounce.updated_at = utc_now()
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -189,9 +192,10 @@ class BounceService:
|
||||
try:
|
||||
# SMTP2GO timestamps are typically Unix timestamps
|
||||
if isinstance(timestamp, (int, float)):
|
||||
bounce_date = datetime.fromtimestamp(timestamp)
|
||||
bounce_date = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||
elif isinstance(timestamp, str):
|
||||
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
||||
bounce_date = to_utc_naive(bounce_date)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -252,18 +256,18 @@ class BounceService:
|
||||
db = next(get_db())
|
||||
|
||||
from datetime import timedelta
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
|
||||
cutoff_date = utc_now() - timedelta(days=days_old)
|
||||
|
||||
# Only deactivate soft bounces, keep hard bounces and complaints active
|
||||
result = db.query(EmailBounce).filter(
|
||||
EmailBounce.bounce_type == BounceType.SOFT,
|
||||
EmailBounce.is_active == True,
|
||||
EmailBounce.bounce_date < cutoff_date
|
||||
).update({'is_active': False, 'updated_at': datetime.utcnow()})
|
||||
).update({'is_active': False, 'updated_at': utc_now()})
|
||||
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
# Create a singleton instance
|
||||
bounce_service = BounceService()
|
||||
bounce_service = BounceService()
|
||||
|
||||
@@ -2,6 +2,7 @@ import httpx
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from ..core.database import get_db
|
||||
from ..core.datetime import utc_now
|
||||
from ..models.models import EmailTemplate
|
||||
from sqlalchemy.orm import Session
|
||||
from ..core.config import settings
|
||||
@@ -147,7 +148,7 @@ class EmailService:
|
||||
"payment_amount": f"£{payment_amount:.2f}",
|
||||
"payment_method": payment_method,
|
||||
"renewal_date": renewal_date,
|
||||
"payment_date": datetime.now().strftime("%d %B %Y"),
|
||||
"payment_date": utc_now().strftime("%d %B %Y"),
|
||||
"app_name": settings.APP_NAME
|
||||
}
|
||||
return await self.send_templated_email("membership_activation", to_email, variables, db)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
if str(APP_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(APP_ROOT))
|
||||
@@ -0,0 +1,113 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.users import (
|
||||
_deserialize_answer_value,
|
||||
_normalize_answer_value,
|
||||
_normalize_volunteer_level,
|
||||
_parse_options,
|
||||
_serialize_options,
|
||||
)
|
||||
from app.models.models import ProfileQuestion
|
||||
from app.schemas import QuestionOption
|
||||
|
||||
|
||||
def make_question(input_type: str, options_json: str | None = None) -> ProfileQuestion:
|
||||
return ProfileQuestion(
|
||||
key=f"{input_type}_question",
|
||||
label=f"{input_type.title()} Question",
|
||||
input_type=input_type,
|
||||
options_json=options_json,
|
||||
)
|
||||
|
||||
|
||||
def test_option_parsing_and_serialization_filters_invalid_items() -> None:
|
||||
assert _parse_options('[{"label":" Yes ","value":" yes "}, {"label":"","value":"no"}, "bad"]') == [
|
||||
{"label": "Yes", "value": "yes"}
|
||||
]
|
||||
assert _parse_options("not-json") == []
|
||||
|
||||
serialized = _serialize_options([QuestionOption(label="Private Pilot", value="ppl")])
|
||||
|
||||
assert _parse_options(serialized) == [{"label": "Private Pilot", "value": "ppl"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(True, "true"),
|
||||
("yes", "true"),
|
||||
("0", "false"),
|
||||
(False, "false"),
|
||||
],
|
||||
)
|
||||
def test_boolean_answers_are_normalized(value: object, expected: str) -> None:
|
||||
assert _normalize_answer_value(make_question("boolean"), value) == expected
|
||||
|
||||
|
||||
def test_invalid_boolean_answer_raises_400() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_normalize_answer_value(make_question("boolean"), "maybe")
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(3, "3"),
|
||||
("3.50", "3.5"),
|
||||
(date(2026, 5, 4), "2026-05-04"),
|
||||
(datetime(2026, 5, 4, 12, 30), "2026-05-04"),
|
||||
],
|
||||
)
|
||||
def test_number_and_date_answers_are_normalized(value: object, expected: str) -> None:
|
||||
input_type = "date" if isinstance(value, (date, datetime)) else "number"
|
||||
|
||||
assert _normalize_answer_value(make_question(input_type), value) == expected
|
||||
|
||||
|
||||
def test_select_answers_must_match_configured_options() -> None:
|
||||
question = make_question("select", '[{"label":"Private Pilot","value":"ppl"}]')
|
||||
|
||||
assert _normalize_answer_value(question, "ppl") == "ppl"
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_normalize_answer_value(question, "cpl")
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_empty_answers_clear_existing_values() -> None:
|
||||
assert _normalize_answer_value(make_question("text"), "") is None
|
||||
assert _normalize_answer_value(make_question("text"), None) is None
|
||||
|
||||
|
||||
def test_answer_deserialization_restores_frontend_types() -> None:
|
||||
assert _deserialize_answer_value(make_question("boolean"), "true") is True
|
||||
assert _deserialize_answer_value(make_question("boolean"), "false") is False
|
||||
assert _deserialize_answer_value(make_question("number"), "10") == 10
|
||||
assert _deserialize_answer_value(make_question("number"), "10.5") == 10.5
|
||||
assert _deserialize_answer_value(make_question("text"), "SASA") == "SASA"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("yes", "yes"),
|
||||
("true", "yes"),
|
||||
("0", "no"),
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_volunteer_level_accepts_boolean_like_values(value: str | None, expected: str | None) -> None:
|
||||
assert _normalize_volunteer_level(value) == expected
|
||||
|
||||
|
||||
def test_invalid_volunteer_level_raises_400() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_normalize_volunteer_level("sometimes")
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
@@ -6,7 +6,7 @@ pydantic-settings==2.6.1
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.23
|
||||
sqlalchemy==2.0.49
|
||||
pymysql==1.1.0
|
||||
cryptography==41.0.7
|
||||
alembic==1.13.0
|
||||
@@ -28,3 +28,6 @@ email-validator==2.1.0
|
||||
aiofiles==23.2.1
|
||||
Jinja2==3.1.2
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Tests
|
||||
pytest==8.3.4
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.5"
|
||||
"vite": "^5.0.5",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
+3985
-164
File diff suppressed because it is too large
Load Diff
+44
-14
@@ -1,31 +1,61 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ConfirmProvider } from './contexts/ConfirmContext';
|
||||
import Register from './pages/Register';
|
||||
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 AppFooter from './components/layout/AppFooter';
|
||||
import CookieBanner from './components/layout/CookieBanner';
|
||||
import './App.css';
|
||||
import { useState } from 'react';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [cookieDismissed, setCookieDismissed] = useState(
|
||||
() => localStorage.getItem('cookie_notice_dismissed') === 'true'
|
||||
);
|
||||
|
||||
const dismissCookies = () => {
|
||||
localStorage.setItem('cookie_notice_dismissed', 'true');
|
||||
setCookieDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureFlagProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/email-templates" element={<EmailTemplates />} />
|
||||
<Route path="/membership-tiers" element={<MembershipTiers />} />
|
||||
<Route path="/bounce-management" element={<BounceManagement />} />
|
||||
</Routes>
|
||||
<ConfirmProvider>
|
||||
<ToastProvider>
|
||||
<div className="app-shell">
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/dashboard/:tab" element={<Dashboard />} />
|
||||
<Route path="/dashboard/admin/:section" element={<Dashboard />} />
|
||||
<Route path="/email-templates" element={<Navigate to="/dashboard/admin/email" replace />} />
|
||||
<Route path="/membership-tiers" element={<Navigate to="/dashboard/admin/tiers" replace />} />
|
||||
<Route path="/bounce-management" element={<Navigate to="/dashboard/admin/bounces" replace />} />
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
<Route path="/terms-of-service" element={<TermsOfService />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<AppFooter />
|
||||
{!cookieDismissed && (
|
||||
<CookieBanner onDismiss={dismissCookies} />
|
||||
)}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</ConfirmProvider>
|
||||
</BrowserRouter>
|
||||
</FeatureFlagProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ProfileQuestion,
|
||||
ProfileQuestionInputType,
|
||||
ProfileQuestionOption,
|
||||
ProfileQuestionUpsertData,
|
||||
userService
|
||||
} from '../services/membershipService';
|
||||
import { useConfirm } from '../contexts/ConfirmContext';
|
||||
|
||||
interface AdminProfileQuestionManagerProps {
|
||||
onQuestionsChanged?: () => void;
|
||||
openEditorToken?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
type QuestionSortKey = 'order' | 'label' | 'type' | 'key' | 'status';
|
||||
|
||||
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
|
||||
|
||||
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
|
||||
if (!options || options.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return options.map((option) => `${option.label}|${option.value}`).join('\n');
|
||||
};
|
||||
|
||||
const textToOptions = (value: string): ProfileQuestionOption[] => {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [labelPart, valuePart] = line.split('|');
|
||||
const label = (labelPart || '').trim();
|
||||
const optionValue = (valuePart || labelPart || '').trim();
|
||||
return { label, value: optionValue };
|
||||
})
|
||||
.filter((option) => option.label.length > 0 && option.value.length > 0);
|
||||
};
|
||||
|
||||
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({
|
||||
onQuestionsChanged,
|
||||
openEditorToken = 0,
|
||||
searchTerm = ''
|
||||
}) => {
|
||||
const { confirm } = useConfirm();
|
||||
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<QuestionSortKey>('order');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const pageSize = 10;
|
||||
|
||||
const emptyForm: ProfileQuestionUpsertData = {
|
||||
key: '',
|
||||
label: '',
|
||||
help_text: '',
|
||||
input_type: 'text',
|
||||
placeholder: '',
|
||||
options: null,
|
||||
is_required: false,
|
||||
is_active: true,
|
||||
admin_only_edit: false,
|
||||
display_order: 0,
|
||||
depends_on_question_id: null,
|
||||
depends_on_value: null
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<ProfileQuestionUpsertData>(emptyForm);
|
||||
const [optionsText, setOptionsText] = useState('');
|
||||
|
||||
const loadQuestions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await userService.getAdminProfileQuestions(true);
|
||||
setQuestions(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load questions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (openEditorToken > 0) {
|
||||
setFormData(emptyForm);
|
||||
setOptionsText('');
|
||||
setEditingQuestionId(null);
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
}, [openEditorToken]);
|
||||
|
||||
const dependencyCandidates = useMemo(
|
||||
() => 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 = searchTerm.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return questions;
|
||||
}
|
||||
return questions.filter((question) =>
|
||||
question.label.toLowerCase().includes(term) ||
|
||||
question.key.toLowerCase().includes(term)
|
||||
);
|
||||
}, [questions, searchTerm]);
|
||||
|
||||
const sortedQuestions = useMemo(() => {
|
||||
const compareValues = (left: string | number, right: string | number) => {
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
return left - right;
|
||||
}
|
||||
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
};
|
||||
|
||||
return [...filteredQuestions].sort((left, right) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'order':
|
||||
result = compareValues(left.display_order ?? 0, right.display_order ?? 0);
|
||||
break;
|
||||
case 'label':
|
||||
result = compareValues(left.label, right.label);
|
||||
break;
|
||||
case 'type':
|
||||
result = compareValues(left.input_type, right.input_type);
|
||||
break;
|
||||
case 'key':
|
||||
result = compareValues(left.key, right.key);
|
||||
break;
|
||||
case 'status':
|
||||
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = compareValues(left.label, right.label);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
}, [filteredQuestions, sortDirection, sortKey]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sortedQuestions.length / pageSize));
|
||||
const paginatedQuestions = useMemo(
|
||||
() => sortedQuestions.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
||||
[sortedQuestions, currentPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData(emptyForm);
|
||||
setOptionsText('');
|
||||
setEditingQuestionId(null);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
resetForm();
|
||||
setIsEditorOpen(false);
|
||||
};
|
||||
|
||||
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));
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
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();
|
||||
closeEditor();
|
||||
onQuestionsChanged?.();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to save question');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (questionId: number) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Deactivate question',
|
||||
message: 'Deactivate this question? Existing answers are kept.',
|
||||
confirmLabel: 'Deactivate',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.deactivateAdminProfileQuestion(questionId);
|
||||
await loadQuestions();
|
||||
onQuestionsChanged?.();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to deactivate question');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSort = (nextKey: QuestionSortKey) => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(nextKey);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
|
||||
<span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4 10.5 8 6l4 4.5" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p className="admin-empty">Loading questions...</p>
|
||||
) : (
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-question-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'order' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('order')}>
|
||||
<span>Order</span>{renderSortArrow(sortKey === 'order', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'label' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('label')}>
|
||||
<span>Label</span>{renderSortArrow(sortKey === 'label', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'type' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('type')}>
|
||||
<span>Type</span>{renderSortArrow(sortKey === 'type', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
|
||||
<span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
|
||||
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedQuestions.map((question) => (
|
||||
<tr key={question.id}>
|
||||
<td>{question.display_order}</td>
|
||||
<td>
|
||||
{question.label}
|
||||
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
|
||||
</td>
|
||||
<td>{question.input_type}</td>
|
||||
<td>{question.key}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${question.is_active ? 'status-active' : 'status-expired'}`}>
|
||||
{question.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-button-row">
|
||||
<button className="btn btn-secondary" onClick={() => handleEdit(question)}>
|
||||
Edit
|
||||
</button>
|
||||
{question.is_active && (
|
||||
<button className="btn btn-danger" onClick={() => handleDeactivate(question.id)}>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredQuestions.length === 0 && (
|
||||
<p className="admin-empty admin-table-empty">No questions match your search.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-pagination admin-table-footer">
|
||||
<span>Page {currentPage} of {totalPages}</span>
|
||||
<div className="admin-pager-controls">
|
||||
<button className="admin-pager-button" disabled={currentPage <= 1} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))} aria-label="Previous page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5 6 8l4.5 4.5" /></svg>
|
||||
</button>
|
||||
<button className="admin-pager-button" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} aria-label="Next page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10 8l-4.5 4.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditorOpen && (
|
||||
<div className="drawer-overlay" onClick={closeEditor}>
|
||||
<aside className="user-drawer property-drawer admin-question-drawer" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="drawer-header">
|
||||
<div className="drawer-header-main">
|
||||
<span className="drawer-eyebrow">Profile Question</span>
|
||||
<h3>{editingQuestionId ? 'Edit Question' : 'Create Question'}</h3>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<button className="drawer-close" onClick={closeEditor}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
<div className="drawer-section">
|
||||
<div className="admin-form-grid">
|
||||
<input
|
||||
className="admin-field"
|
||||
type="text"
|
||||
placeholder="Question key"
|
||||
value={formData.key}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="admin-field"
|
||||
type="text"
|
||||
placeholder="Question label"
|
||||
value={formData.label}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
className="admin-field admin-field-textarea"
|
||||
placeholder="Help text"
|
||||
value={formData.help_text || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<div className="admin-field-grid">
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.input_type}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
|
||||
>
|
||||
{INPUT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="admin-field"
|
||||
type="number"
|
||||
placeholder="Display order"
|
||||
value={formData.display_order ?? 0}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="admin-field"
|
||||
type="text"
|
||||
placeholder="Placeholder"
|
||||
value={formData.placeholder || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-field-grid">
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.depends_on_question_id ?? ''}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
depends_on_question_id: nextValue ? Number(nextValue) : null,
|
||||
depends_on_value: null
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">No dependency</option>
|
||||
{dependencyCandidates.map((question) => (
|
||||
<option key={question.id} value={question.id}>{question.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{!selectedDependencyQuestion && (
|
||||
<input
|
||||
className="admin-field admin-field-disabled"
|
||||
type="text"
|
||||
placeholder="Choose a dependency question first"
|
||||
value=""
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'select' && (
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
{selectedDependencyQuestion.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'boolean' && (
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
|
||||
<input
|
||||
className="admin-field"
|
||||
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
|
||||
placeholder="Show when parent answer equals..."
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.input_type === 'select' && (
|
||||
<textarea
|
||||
className="admin-field admin-field-textarea"
|
||||
value={optionsText}
|
||||
onChange={(event) => setOptionsText(event.target.value)}
|
||||
rows={4}
|
||||
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="admin-switch-group admin-question-switches">
|
||||
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.is_required)} onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))} />Required</label>
|
||||
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.admin_only_edit)} onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))} />Admin-only edits</label>
|
||||
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.is_active)} onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))} />Active</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
|
||||
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={closeEditor}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProfileQuestionManager;
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useConfirm } from '../contexts/ConfirmContext';
|
||||
import { formatLondonDateTime, utcMillis } from '../utils/timezone';
|
||||
|
||||
interface BounceRecord {
|
||||
id: number;
|
||||
@@ -22,12 +25,23 @@ interface BounceStats {
|
||||
};
|
||||
}
|
||||
|
||||
const BounceManagement: React.FC = () => {
|
||||
interface BounceManagementProps {
|
||||
cleanupToken?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
type BounceSortKey = 'email' | 'type' | 'reason' | 'date' | 'status';
|
||||
|
||||
const BounceManagement: React.FC<BounceManagementProps> = ({ cleanupToken = 0, searchTerm = '' }) => {
|
||||
const toast = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [bounces, setBounces] = useState<BounceRecord[]>([]);
|
||||
const [stats, setStats] = useState<BounceStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchEmail, setSearchEmail] = useState('');
|
||||
const [filteredBounces, setFilteredBounces] = useState<BounceRecord[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<BounceSortKey>('date');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
fetchBounces();
|
||||
@@ -35,16 +49,10 @@ const BounceManagement: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchEmail.trim() === '') {
|
||||
setFilteredBounces(bounces);
|
||||
} else {
|
||||
setFilteredBounces(
|
||||
bounces.filter(bounce =>
|
||||
bounce.email.toLowerCase().includes(searchEmail.toLowerCase())
|
||||
)
|
||||
);
|
||||
if (cleanupToken > 0) {
|
||||
void handleCleanupOldBounces();
|
||||
}
|
||||
}, [bounces, searchEmail]);
|
||||
}, [cleanupToken]);
|
||||
|
||||
const fetchBounces = async () => {
|
||||
try {
|
||||
@@ -73,264 +81,197 @@ const BounceManagement: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeactivateBounce = async (bounceId: number) => {
|
||||
if (!window.confirm('Are you sure you want to deactivate this bounce record?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: 'Resolve bounce record',
|
||||
message: 'Are you sure you want to deactivate this bounce record?',
|
||||
confirmLabel: 'Resolve',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(`/api/v1/email/bounces/${bounceId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
fetchBounces(); // Refresh the list
|
||||
fetchStats(); // Refresh stats
|
||||
fetchBounces();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Error deactivating bounce:', error);
|
||||
alert('Failed to deactivate bounce record');
|
||||
toast.error('Failed to deactivate bounce record.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanupOldBounces = async () => {
|
||||
if (!window.confirm('Are you sure you want to cleanup old soft bounces? This will deactivate soft bounces older than 365 days.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: 'Cleanup old bounces',
|
||||
message: 'Are you sure you want to cleanup old soft bounces?',
|
||||
confirmLabel: 'Cleanup',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.post('/api/v1/email/bounces/cleanup', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
alert(response.data.message);
|
||||
fetchBounces(); // Refresh the list
|
||||
fetchStats(); // Refresh stats
|
||||
toast.success(response.data.message);
|
||||
fetchBounces();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up bounces:', error);
|
||||
alert('Failed to cleanup old bounces');
|
||||
toast.error('Failed to cleanup old bounces.');
|
||||
}
|
||||
};
|
||||
|
||||
const getBounceTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'hard': return '#dc3545';
|
||||
case 'soft': return '#ffc107';
|
||||
case 'complaint': return '#fd7e14';
|
||||
case 'unsubscribe': return '#6c757d';
|
||||
default: return '#6c757d';
|
||||
const filteredBounces = bounces.filter((bounce) =>
|
||||
searchTerm.trim() === '' ? true : bounce.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const sortedBounces = [...filteredBounces].sort((left, right) => {
|
||||
const compareValues = (a: string | number, b: string | number) => {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||
};
|
||||
|
||||
let result = 0;
|
||||
switch (sortKey) {
|
||||
case 'email':
|
||||
result = compareValues(left.email, right.email);
|
||||
break;
|
||||
case 'type':
|
||||
result = compareValues(left.bounce_type, right.bounce_type);
|
||||
break;
|
||||
case 'reason':
|
||||
result = compareValues(left.bounce_reason || 'ZZZ', right.bounce_reason || 'ZZZ');
|
||||
break;
|
||||
case 'date':
|
||||
result = compareValues(utcMillis(left.bounce_date), utcMillis(right.bounce_date));
|
||||
break;
|
||||
case 'status':
|
||||
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = compareValues(left.email, right.email);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredBounces.length / pageSize));
|
||||
const paginatedBounces = sortedBounces.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
const formatDate = (dateString: string) => formatLondonDateTime(dateString);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const toggleSort = (nextKey: BounceSortKey) => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(nextKey);
|
||||
setSortDirection(nextKey === 'date' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
|
||||
<span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4 10.5 8 6l4 4.5" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>Loading bounce data...</div>
|
||||
</div>
|
||||
);
|
||||
return <p className="admin-empty">Loading bounce data...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Statistics Cards */}
|
||||
{stats && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '30px'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Total Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
|
||||
{stats.total_bounces}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Active Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
|
||||
{stats.active_bounces}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Hard Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
|
||||
{stats.bounce_types.hard}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Soft Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
|
||||
{stats.bounce_types.soft}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-grid">
|
||||
<div className="admin-stat-card"><span>Total Bounces</span><strong>{stats.total_bounces}</strong></div>
|
||||
<div className="admin-stat-card attention"><span>Active Bounces</span><strong>{stats.active_bounces}</strong></div>
|
||||
<div className="admin-stat-card"><span>Hard Bounces</span><strong>{stats.bounce_types.hard}</strong></div>
|
||||
<div className="admin-stat-card"><span>Soft Bounces</span><strong>{stats.bounce_types.soft}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<label htmlFor="search" style={{ fontWeight: 'bold' }}>Search by Email:</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
value={searchEmail}
|
||||
onChange={(e) => setSearchEmail(e.target.value)}
|
||||
placeholder="Enter email address..."
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ced4da',
|
||||
borderRadius: '4px',
|
||||
minWidth: '250px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCleanupOldBounces}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cleanup Old Bounces
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bounce Records Table */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid #dee2e6',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, color: '#495057' }}>Bounce Records</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse'
|
||||
}}>
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Email</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Type</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Reason</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Date</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Actions</th>
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'email' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('email')}>
|
||||
<span>Email</span>{renderSortArrow(sortKey === 'email', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'type' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('type')}>
|
||||
<span>Type</span>{renderSortArrow(sortKey === 'type', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'reason' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('reason')}>
|
||||
<span>Reason</span>{renderSortArrow(sortKey === 'reason', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'date' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('date')}>
|
||||
<span>Date</span>{renderSortArrow(sortKey === 'date', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
|
||||
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBounces.length === 0 ? (
|
||||
{paginatedBounces.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{searchEmail ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
||||
<td colSpan={6} className="admin-table-empty">
|
||||
{searchTerm ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredBounces.map((bounce) => (
|
||||
<tr key={bounce.id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div style={{ fontWeight: '500' }}>{bounce.email}</div>
|
||||
{bounce.smtp2go_message_id && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#6c757d' }}>
|
||||
ID: {bounce.smtp2go_message_id}
|
||||
</div>
|
||||
)}
|
||||
paginatedBounces.map((bounce) => (
|
||||
<tr key={bounce.id}>
|
||||
<td>
|
||||
<strong>{bounce.email}</strong>
|
||||
{bounce.smtp2go_message_id && <span className="muted-line">ID: {bounce.smtp2go_message_id}</span>}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
backgroundColor: getBounceTypeColor(bounce.bounce_type),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{bounce.bounce_type}
|
||||
<td>
|
||||
<span className={`status-badge ${
|
||||
bounce.bounce_type === 'soft' ? 'status-pending' :
|
||||
bounce.bounce_type === 'hard' ? 'status-expired' :
|
||||
'status-active'
|
||||
}`}>
|
||||
{bounce.bounce_type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', maxWidth: '300px' }}>
|
||||
<div style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{bounce.bounce_reason || 'No reason provided'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{formatDate(bounce.bounce_date)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
color: bounce.is_active ? '#dc3545' : '#28a745',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{bounce.is_active ? 'Active' : 'Resolved'}
|
||||
<td>{bounce.bounce_reason || 'No reason provided'}</td>
|
||||
<td>{formatDate(bounce.bounce_date)}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${bounce.is_active ? 'status-expired' : 'status-active'}`}>
|
||||
{bounce.is_active ? 'ACTIVE' : 'RESOLVED'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td>
|
||||
{bounce.is_active && (
|
||||
<button
|
||||
onClick={() => handleDeactivateBounce(bounce.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-primary" onClick={() => handleDeactivateBounce(bounce.id)}>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
@@ -341,9 +282,20 @@ const BounceManagement: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="admin-pagination admin-table-footer">
|
||||
<span>Page {currentPage} of {totalPages}</span>
|
||||
<div className="admin-pager-controls">
|
||||
<button className="admin-pager-button" disabled={currentPage <= 1} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))} aria-label="Previous page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5 6 8l4.5 4.5" /></svg>
|
||||
</button>
|
||||
<button className="admin-pager-button" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} aria-label="Next page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10 8l-4.5 4.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BounceManagement;
|
||||
export default BounceManagement;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface EmailTemplate {
|
||||
template_key: string;
|
||||
@@ -7,22 +8,55 @@ interface EmailTemplate {
|
||||
subject: string;
|
||||
html_body: string;
|
||||
text_body: string;
|
||||
variables: string; // This comes as JSON string from backend
|
||||
variables: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const EmailTemplateManagement: React.FC = () => {
|
||||
interface EmailTemplateManagementProps {
|
||||
searchTerm?: string;
|
||||
statusFilter?: 'all' | 'active' | 'inactive';
|
||||
refreshToken?: number;
|
||||
}
|
||||
|
||||
type TemplateSortKey = 'name' | 'key' | 'subject' | 'variables' | 'status';
|
||||
|
||||
const parseTemplateVariables = (variables: string): string[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(variables);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return variables
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
};
|
||||
|
||||
const EmailTemplateManagement: React.FC<EmailTemplateManagementProps> = ({
|
||||
searchTerm = '',
|
||||
statusFilter = 'all',
|
||||
refreshToken = 0
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<TemplateSortKey>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
void fetchTemplates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
void fetchTemplates();
|
||||
}
|
||||
}, [refreshToken]);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.get('/api/v1/email-templates/', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
@@ -30,149 +64,202 @@ const EmailTemplateManagement: React.FC = () => {
|
||||
setTemplates(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching email templates:', error);
|
||||
toast.error('Failed to load email templates.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTemplate = (template: EmailTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setShowEditForm(true);
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setShowEditForm(false);
|
||||
setEditingTemplate(null);
|
||||
fetchTemplates(); // Refresh the list
|
||||
toast.success('Email template updated.');
|
||||
void fetchTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error updating email template:', error);
|
||||
toast.error('Failed to update email template.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setShowEditForm(false);
|
||||
setEditingTemplate(null);
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
|
||||
return templates.filter((template) => {
|
||||
const matchesSearch = normalizedSearch === '' || [
|
||||
template.name,
|
||||
template.template_key,
|
||||
template.subject
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'active' && template.is_active) ||
|
||||
(statusFilter === 'inactive' && !template.is_active);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [searchTerm, statusFilter, templates]);
|
||||
|
||||
const sortedTemplates = useMemo(() => {
|
||||
const compareValues = (left: string | number, right: string | number) => {
|
||||
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
};
|
||||
|
||||
const sorted = [...filteredTemplates].sort((left, right) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'name':
|
||||
result = compareValues(left.name, right.name);
|
||||
break;
|
||||
case 'key':
|
||||
result = compareValues(left.template_key, right.template_key);
|
||||
break;
|
||||
case 'subject':
|
||||
result = compareValues(left.subject, right.subject);
|
||||
break;
|
||||
case 'variables':
|
||||
result = compareValues(parseTemplateVariables(left.variables).length, parseTemplateVariables(right.variables).length);
|
||||
break;
|
||||
case 'status':
|
||||
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = compareValues(left.name, right.name);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [filteredTemplates, sortDirection, sortKey]);
|
||||
|
||||
const toggleSort = (nextKey: TemplateSortKey) => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(nextKey);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
|
||||
<span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4 10.5 8 6l4 4.5" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading email templates...</div>;
|
||||
return <p className="admin-empty">Loading email templates...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<button
|
||||
onClick={fetchTemplates}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Refresh Templates
|
||||
</button>
|
||||
<div>
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'name' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('name')}>
|
||||
<span>Template</span>{renderSortArrow(sortKey === 'name', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
|
||||
<span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'subject' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('subject')}>
|
||||
<span>Subject</span>{renderSortArrow(sortKey === 'subject', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'variables' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('variables')}>
|
||||
<span>Variables</span>{renderSortArrow(sortKey === 'variables', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
|
||||
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className="admin-table-empty" colSpan={6}>No templates match the current filters.</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedTemplates.map((template) => {
|
||||
const variables = parseTemplateVariables(template.variables);
|
||||
return (
|
||||
<tr key={template.template_key} onClick={() => setEditingTemplate(template)}>
|
||||
<td>
|
||||
<strong>{template.name}</strong>
|
||||
<div className="muted-line">{variables.length} variable{variables.length === 1 ? '' : 's'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{template.template_key}</code>
|
||||
</td>
|
||||
<td>{template.subject}</td>
|
||||
<td>
|
||||
{variables.length > 0 ? (
|
||||
<div className="admin-inline-list">
|
||||
{variables.slice(0, 3).map((variable) => (
|
||||
<span key={variable} className="admin-inline-chip">{variable}</span>
|
||||
))}
|
||||
{variables.length > 3 && <span className="muted-line">+{variables.length - 3} more</span>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-line">None</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge ${template.is_active ? 'status-active' : 'status-expired'}`}>
|
||||
{template.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-button-row">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingTemplate(template);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.template_key}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{template.name}</h3>
|
||||
<div>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: template.is_active ? '#d4edda' : '#f8d7da',
|
||||
color: template.is_active ? '#155724' : '#721c24'
|
||||
}}>
|
||||
{template.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleEditTemplate(template)}
|
||||
style={{
|
||||
marginLeft: '10px',
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong style={{ color: '#666' }}>Key:</strong> <code style={{ backgroundColor: '#f8f9fa', padding: '2px 4px', borderRadius: '3px' }}>{template.template_key}</code>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong style={{ color: '#666' }}>Subject:</strong> <span style={{ color: '#333' }}>{template.subject}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<strong style={{ color: '#666' }}>Variables:</strong>
|
||||
<div style={{ marginTop: '5px', fontFamily: 'monospace', fontSize: '14px', color: '#333' }}>
|
||||
{(() => {
|
||||
try {
|
||||
const vars = JSON.parse(template.variables);
|
||||
return Array.isArray(vars) ? vars.join(', ') : template.variables;
|
||||
} catch {
|
||||
return template.variables;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong style={{ color: '#666' }}>HTML Body Preview:</strong>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '4px',
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.4',
|
||||
color: '#333'
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: template.html_body.substring(0, 300) + '...' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showEditForm && editingTemplate && (
|
||||
{editingTemplate && (
|
||||
<EmailTemplateEditForm
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
onCancel={handleCancelEdit}
|
||||
onCancel={() => setEditingTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -186,6 +273,7 @@ interface EmailTemplateEditFormProps {
|
||||
}
|
||||
|
||||
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
|
||||
const [previewMode, setPreviewMode] = useState<'rendered' | 'html' | 'text'>('rendered');
|
||||
const [formData, setFormData] = useState({
|
||||
name: template.name,
|
||||
subject: template.subject,
|
||||
@@ -202,200 +290,188 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
|
||||
is_active: template.is_active
|
||||
});
|
||||
|
||||
const handleChange = (field: keyof EmailTemplate, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
const handleChange = (field: keyof typeof formData, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const dataToSave = {
|
||||
onSave({
|
||||
template_key: template.template_key,
|
||||
...formData,
|
||||
variables: JSON.stringify(formData.variables)
|
||||
};
|
||||
onSave(dataToSave);
|
||||
}; return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '800px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>Edit Email Template: {template.name}</h3>
|
||||
});
|
||||
};
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Template Key:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={template.template_key}
|
||||
disabled
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}
|
||||
/>
|
||||
<small style={{ color: '#666' }}>Template key cannot be changed</small>
|
||||
const previewDocument = useMemo(() => {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${formData.html_body}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}, [formData.html_body]);
|
||||
|
||||
return (
|
||||
<div className="drawer-overlay" onClick={onCancel}>
|
||||
<aside className="user-drawer property-drawer" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="drawer-header">
|
||||
<div className="drawer-header-main">
|
||||
<span className="drawer-eyebrow">Template Editor</span>
|
||||
<h3>Edit Email Template</h3>
|
||||
<p>{template.name}</p>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<button className="drawer-close" type="button" onClick={onCancel}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-hero">
|
||||
<div className="drawer-hero-grid">
|
||||
<div className="drawer-hero-card">
|
||||
<span className="drawer-hero-label">Key</span>
|
||||
<span className="drawer-hero-value">{template.template_key}</span>
|
||||
</div>
|
||||
<div className="drawer-hero-card">
|
||||
<span className="drawer-hero-label">Status</span>
|
||||
<span className="drawer-hero-value">
|
||||
<span className={`status-badge ${formData.is_active ? 'status-active' : 'status-expired'}`}>
|
||||
{formData.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-header">
|
||||
<div>
|
||||
<h4>Template Content</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Template Key</label>
|
||||
<input type="text" value={template.template_key} disabled className="admin-field admin-field-disabled" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} className="admin-field" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Subject</label>
|
||||
<input type="text" value={formData.subject} onChange={(e) => handleChange('subject', e.target.value)} className="admin-field" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Variables</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.variables.join(', ')}
|
||||
onChange={(e) => handleChange('variables', e.target.value.split(',').map((v) => v.trim()).filter(Boolean))}
|
||||
className="admin-field"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>HTML Body</label>
|
||||
<textarea
|
||||
value={formData.html_body}
|
||||
onChange={(e) => handleChange('html_body', e.target.value)}
|
||||
rows={15}
|
||||
className="admin-field admin-field-textarea admin-code-textarea"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Text Body</label>
|
||||
<textarea
|
||||
value={formData.text_body}
|
||||
onChange={(e) => handleChange('text_body', e.target.value)}
|
||||
rows={10}
|
||||
className="admin-field admin-field-textarea admin-code-textarea"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="admin-switch-row" style={{ marginBottom: '20px' }}>
|
||||
<input type="checkbox" checked={formData.is_active} onChange={(e) => handleChange('is_active', e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
<div className="table-button-row" style={{ justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Name:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-header">
|
||||
<div>
|
||||
<h4>Preview</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Subject:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => handleChange('subject', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="email-preview-tabs" role="tablist" aria-label="Email preview mode">
|
||||
<button
|
||||
type="button"
|
||||
className={previewMode === 'rendered' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||
onClick={() => setPreviewMode('rendered')}
|
||||
>
|
||||
Rendered
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={previewMode === 'html' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||
onClick={() => setPreviewMode('html')}
|
||||
>
|
||||
HTML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={previewMode === 'text' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||
onClick={() => setPreviewMode('text')}
|
||||
>
|
||||
Text
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Variables (comma-separated):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.variables.join(', ')}
|
||||
onChange={(e) => handleChange('variables', e.target.value.split(',').map(v => v.trim()))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{previewMode === 'rendered' && (
|
||||
<div className="email-preview-frame-shell">
|
||||
<iframe
|
||||
title={`${template.name} preview`}
|
||||
className="email-preview-frame"
|
||||
srcDoc={previewDocument}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
HTML Body:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.html_body}
|
||||
onChange={(e) => handleChange('html_body', e.target.value)}
|
||||
rows={15}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{previewMode === 'html' && (
|
||||
<pre className="email-preview-code">{formData.html_body}</pre>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Text Body:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.text_body}
|
||||
onChange={(e) => handleChange('text_body', e.target.value)}
|
||||
rows={10}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{previewMode === 'text' && (
|
||||
<pre className="email-preview-code">{formData.text_body}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplateManagement;
|
||||
export default EmailTemplateManagement;
|
||||
|
||||
@@ -5,30 +5,26 @@ const FeatureFlagStatus: React.FC = () => {
|
||||
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>;
|
||||
return <div style={{ fontSize: '14px', color: '#8D96A3' }}>Loading feature flags...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>;
|
||||
return <div style={{ fontSize: '14px', color: '#EE6368' }}>Error loading feature flags</div>;
|
||||
}
|
||||
|
||||
if (!flags) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleReload = async () => {
|
||||
try {
|
||||
await reloadFlags();
|
||||
console.log('Feature flags reloaded');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload feature flags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
|
||||
|
||||
<div className="admin-surface" style={{ marginBottom: '20px' }}>
|
||||
<div className="admin-surface-header">
|
||||
<div>
|
||||
<h4>Feature Flags Status</h4>
|
||||
<p>Environment-driven switches for admin-controlled behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
||||
{Object.entries(flags.flags).map(([name, value]) => (
|
||||
<div
|
||||
@@ -37,23 +33,28 @@ const FeatureFlagStatus: React.FC = () => {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(16,18,22,0.72)',
|
||||
borderTop: '1px solid rgba(64,71,80,0.55)',
|
||||
borderBottom: '1px solid rgba(34,38,44,0.96)',
|
||||
borderLeft: '1px solid rgba(42,46,52,0.78)',
|
||||
borderRight: '1px solid rgba(42,46,52,0.78)',
|
||||
borderRadius: '3px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: '500' }}>
|
||||
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
|
||||
<span style={{ fontWeight: 500, color: '#E6EBF2' }}>
|
||||
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
backgroundColor: value ? '#4CAF50' : '#f44336',
|
||||
color: 'white'
|
||||
borderRadius: '999px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
background: value ? 'rgba(47,162,82,.13)' : 'rgba(92,31,33,.4)',
|
||||
color: value ? '#2FA252' : '#EE6368',
|
||||
border: `1px solid ${value ? 'rgba(47,162,82,.36)' : 'rgba(238,99,104,.42)'}`
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
@@ -61,20 +62,16 @@ const FeatureFlagStatus: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleReload}
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
>
|
||||
|
||||
<button className="btn btn-secondary" onClick={reloadFlags} style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||
Reload Flags
|
||||
</button>
|
||||
|
||||
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
|
||||
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
|
||||
|
||||
<p style={{ fontSize: '12px', color: '#8D96A3', marginTop: '12px', marginBottom: 0 }}>
|
||||
Feature flags are loaded from environment variables. Changes require updating the environment and reloading.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureFlagStatus;
|
||||
export default FeatureFlagStatus;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
||||
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||
import SquarePaymentNew from './SquarePaymentNew';
|
||||
import { londonTodayDateInput } from '../utils/timezone';
|
||||
|
||||
interface MembershipSetupProps {
|
||||
onMembershipCreated: () => void;
|
||||
@@ -85,8 +86,10 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const startDate = new Date().toISOString().split('T')[0];
|
||||
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const startDate = londonTodayDateInput();
|
||||
const endDateValue = new Date(`${startDate}T00:00:00Z`);
|
||||
endDateValue.setUTCFullYear(endDateValue.getUTCFullYear() + 1);
|
||||
const endDate = endDateValue.toISOString().split('T')[0];
|
||||
|
||||
const membershipData: MembershipCreateData = {
|
||||
tier_id: selectedTier.id,
|
||||
@@ -112,47 +115,38 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
|
||||
if (step === 'select') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>Choose Your Membership</h3>
|
||||
<div className="card member-card member-membership-setup">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership Setup</p>
|
||||
<h3>Choose Your Membership</h3>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div className="membership-tier-grid">
|
||||
{tiers.map(tier => (
|
||||
<div
|
||||
key={tier.id}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#0066cc';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 102, 204, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ddd';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="membership-tier-card"
|
||||
onClick={() => handleTierSelect(tier)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h4 style={{ margin: 0, color: '#0066cc' }}>{tier.name}</h4>
|
||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#0066cc' }}>
|
||||
<div className="membership-tier-header">
|
||||
<h4>{tier.name}</h4>
|
||||
<span className="membership-tier-price">
|
||||
£{tier.annual_fee.toFixed(2)}/year
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0', color: '#666', fontSize: '14px' }}>{tier.description}</p>
|
||||
<div style={{ backgroundColor: '#f5f5f5', padding: '12px', borderRadius: '4px' }}>
|
||||
<p className="membership-tier-description">{tier.description}</p>
|
||||
<div className="membership-tier-benefits">
|
||||
<strong>Benefits:</strong>
|
||||
<p style={{ marginTop: '4px', fontSize: '14px' }}>{tier.benefits}</p>
|
||||
<p>{tier.benefits}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -167,12 +161,17 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
|
||||
if (step === 'payment') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>Complete Payment</h3>
|
||||
<div className="card member-card member-membership-setup">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership Setup</p>
|
||||
<h3>Complete Payment</h3>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{selectedTier && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="membership-summary-panel">
|
||||
<h4>Selected Membership: {selectedTier.name}</h4>
|
||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||
<p><strong>Benefits:</strong> {selectedTier.benefits}</p>
|
||||
@@ -180,25 +179,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
)}
|
||||
|
||||
{!paymentMethod && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ marginBottom: '16px' }}>Choose Payment Method</h4>
|
||||
<div className="membership-payment-stage">
|
||||
<h4 className="membership-payment-heading">Choose Payment Method</h4>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div className="membership-payment-options">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePaymentMethodSelect('square')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
<div>
|
||||
<div className="membership-payment-option-copy">
|
||||
<strong>Credit/Debit Card</strong>
|
||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||
<div>
|
||||
Pay securely with Square
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,17 +203,11 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handlePaymentMethodSelect('cash')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
<div>
|
||||
<div className="membership-payment-option-copy">
|
||||
<strong>Cash Payment</strong>
|
||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||
<div>
|
||||
Pay in person or by check
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +216,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -250,7 +237,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
onPaymentSuccess={handleSquarePaymentSuccess}
|
||||
onPaymentError={handleSquarePaymentError}
|
||||
/>
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -268,26 +255,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
|
||||
{paymentMethod === 'cash' && createdMembershipId && (
|
||||
<div>
|
||||
<div style={{
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div className="membership-cash-notice">
|
||||
<strong>Cash Payment Selected</strong>
|
||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||
<p>
|
||||
Your membership will be marked as pending until an administrator confirms payment receipt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="membership-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleCashPayment}
|
||||
disabled={loading}
|
||||
style={{ marginRight: '10px' }}
|
||||
>
|
||||
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
||||
</button>
|
||||
@@ -314,13 +294,18 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
const isCashPayment = paymentMethod === 'cash';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>
|
||||
<div className="card member-card member-membership-setup">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership Setup</p>
|
||||
<h3>
|
||||
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
||||
</h3>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTier && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="membership-summary-panel">
|
||||
<h4>Your Membership Details:</h4>
|
||||
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||
@@ -329,7 +314,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
{isCashPayment ? 'Pending' : 'Active'}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
|
||||
<p className="membership-confirm-copy">
|
||||
{isCashPayment
|
||||
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
|
||||
: 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.'
|
||||
@@ -338,7 +323,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
@@ -354,4 +339,4 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MembershipSetup;
|
||||
export default MembershipSetup;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authService, User } from '../services/membershipService';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatLondonDate } from '../utils/timezone';
|
||||
|
||||
interface ProfileMenuProps {
|
||||
userName: string;
|
||||
@@ -9,7 +11,7 @@ interface ProfileMenuProps {
|
||||
onEditProfile?: () => void;
|
||||
}
|
||||
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => {
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -38,146 +40,55 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onE
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const dropdownStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
minWidth: '280px',
|
||||
maxWidth: '320px',
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
};
|
||||
const formatDate = (dateString: string) => formatLondonDate(dateString);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'relative' }} ref={menuRef}>
|
||||
<div className="profile-menu" ref={menuRef}>
|
||||
<button
|
||||
className="profile-menu-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<span>{userName}</span>
|
||||
<span style={{ fontSize: '12px' }}>▼</span>
|
||||
<span className="profile-menu-chevron">▼</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div style={dropdownStyle}>
|
||||
{/* Profile Details Section */}
|
||||
<div className="profile-menu-dropdown">
|
||||
{user && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #eee',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '4px 4px 0 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 'bold', color: '#333' }}>Profile Details</h4>
|
||||
<div className="profile-menu-summary">
|
||||
<div className="profile-menu-summary-head">
|
||||
<h4>Profile Details</h4>
|
||||
{onEditProfile && (
|
||||
<button
|
||||
className="profile-menu-edit"
|
||||
onClick={() => {
|
||||
onEditProfile();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
background: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#555', lineHeight: '1.6' }}>
|
||||
<p style={{ margin: '4px 0' }}><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
||||
<p style={{ margin: '4px 0' }}><strong>Email:</strong> {user.email}</p>
|
||||
{user.phone && <p style={{ margin: '4px 0' }}><strong>Phone:</strong> {user.phone}</p>}
|
||||
{user.address && <p style={{ margin: '4px 0' }}><strong>Address:</strong> {user.address}</p>}
|
||||
<p style={{ margin: '4px 0' }}><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
||||
<div className="profile-menu-details">
|
||||
<p><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
||||
<p><strong>Email:</strong> {user.email}</p>
|
||||
{user.phone && <p><strong>Phone:</strong> {user.phone}</p>}
|
||||
{user.address && <p><strong>Address:</strong> {user.address}</p>}
|
||||
<p><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Items */}
|
||||
{userRole === 'super_admin' && (
|
||||
<>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderRadius: user ? '0' : '4px 4px 0 0' }}
|
||||
onClick={() => {
|
||||
navigate('/membership-tiers');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Membership Tiers
|
||||
</button>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
|
||||
onClick={() => {
|
||||
navigate('/email-templates');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Email Templates
|
||||
</button>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
|
||||
onClick={() => {
|
||||
navigate('/bounce-management');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Bounce Management
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
borderRadius: '0',
|
||||
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
|
||||
}}
|
||||
className={`profile-menu-item ${user ? '' : 'first'}`}
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px', borderTop: '1px solid #eee' }}
|
||||
className="profile-menu-item last"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Log Out
|
||||
@@ -198,6 +109,7 @@ interface ChangePasswordModalProps {
|
||||
}
|
||||
|
||||
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) => {
|
||||
const toast = useToast();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -226,7 +138,7 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
||||
new_password: newPassword
|
||||
});
|
||||
|
||||
alert('Password changed successfully!');
|
||||
toast.success('Password changed successfully.');
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.detail || 'Failed to change password');
|
||||
@@ -285,33 +197,19 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}>
|
||||
<div className="modal-button-row">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
@@ -322,4 +220,4 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileMenu;
|
||||
export default ProfileMenu;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ProfileAnswerInput, ProfileQuestionForUser } from '../services/membershipService';
|
||||
import {
|
||||
answerToComparable,
|
||||
canEditProfileQuestion,
|
||||
isProfileQuestionVisible,
|
||||
ProfileQuestionAnswerValue
|
||||
} from '../utils/profileQuestionLogic';
|
||||
|
||||
interface ProfileQuestionsFormProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
questions: ProfileQuestionForUser[];
|
||||
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
|
||||
saveLabel?: string;
|
||||
allowAdminManagedEdit?: boolean;
|
||||
surface?: 'member' | 'admin';
|
||||
}
|
||||
|
||||
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
if (question.input_type === 'boolean') return value === true || value === 'true' ? 'Yes' : 'No';
|
||||
if (question.input_type === 'select') {
|
||||
const matchingOption = question.options.find((option) => option.value === String(value));
|
||||
return matchingOption?.label || String(value);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
title,
|
||||
description,
|
||||
questions,
|
||||
onSave,
|
||||
saveLabel = 'Save Answers',
|
||||
allowAdminManagedEdit = false,
|
||||
surface = 'admin'
|
||||
}) => {
|
||||
const initialAnswers = useMemo(() => {
|
||||
const values: Record<number, ProfileQuestionAnswerValue> = {};
|
||||
questions.forEach((question) => {
|
||||
values[question.id] = question.answer ?? null;
|
||||
});
|
||||
return values;
|
||||
}, [questions]);
|
||||
|
||||
const [answers, setAnswers] = useState<Record<number, ProfileQuestionAnswerValue>>(initialAnswers);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
setAnswers(initialAnswers);
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
}, [initialAnswers]);
|
||||
|
||||
const visibleQuestions = useMemo(() => {
|
||||
const byId = new Map<number, ProfileQuestionForUser>();
|
||||
questions.forEach((question) => byId.set(question.id, question));
|
||||
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
|
||||
}, [questions, answers]);
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
const searchTerm = search.trim().toLowerCase();
|
||||
return visibleQuestions.filter((question) => {
|
||||
if (!searchTerm) return true;
|
||||
return (
|
||||
question.label.toLowerCase().includes(searchTerm) ||
|
||||
question.key.toLowerCase().includes(searchTerm) ||
|
||||
(question.help_text || '').toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}, [visibleQuestions, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
||||
|
||||
const paginatedQuestions = useMemo(() => {
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
return filteredQuestions.slice(start, start + pageSize);
|
||||
}, [filteredQuestions, page, totalPages]);
|
||||
|
||||
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
|
||||
const changedAnswers: ProfileAnswerInput[] = questions
|
||||
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
|
||||
.filter((question) => answerToComparable(answers[question.id] ?? null) !== answerToComparable(initialAnswers[question.id] ?? null))
|
||||
.map((question) => ({
|
||||
question_id: question.id,
|
||||
value: answers[question.id] ?? null
|
||||
}));
|
||||
|
||||
await onSave(changedAnswers);
|
||||
setSuccessMessage(changedAnswers.length > 0 ? 'Saved successfully.' : 'No changes to save.');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to save answers.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (question: ProfileQuestionForUser) => {
|
||||
const value = answers[question.id] ?? null;
|
||||
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
|
||||
|
||||
if (disabled && !saving) {
|
||||
return <div className="profile-question-readonly">{formatAnswerForDisplay(question, value)}</div>;
|
||||
}
|
||||
|
||||
if (question.input_type === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : event.target.value === 'true')}
|
||||
disabled={disabled}
|
||||
className="profile-question-input"
|
||||
>
|
||||
<option value="">Prefer not to say</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.input_type === 'select') {
|
||||
return (
|
||||
<select
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
className="profile-question-input"
|
||||
>
|
||||
<option value="">Select an option</option>
|
||||
{question.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.input_type === 'date') {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.input_type === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
|
||||
disabled={disabled}
|
||||
placeholder={question.placeholder || ''}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
placeholder={question.placeholder || ''}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`card profile-questions-form ${surface === 'member' ? 'member-surface' : 'admin-surface'}`}>
|
||||
<h3 className="profile-questions-title">{title}</h3>
|
||||
{description && <p className="profile-questions-description">{description}</p>}
|
||||
|
||||
<div className="profile-questions-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search questions..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{successMessage && <div className="alert alert-success">{successMessage}</div>}
|
||||
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<p className="profile-questions-empty">No questions available.</p>
|
||||
) : (
|
||||
<div className="profile-questions-list">
|
||||
{paginatedQuestions.map((question) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={`profile-question-row ${surface === 'member' ? 'profile-question-row-member' : 'profile-question-row-admin'}`}
|
||||
>
|
||||
<div className="profile-question-meta">
|
||||
<label className="profile-question-label">
|
||||
{question.label}
|
||||
{question.is_required && <span className="profile-question-required"> *</span>}
|
||||
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
|
||||
</label>
|
||||
{question.help_text && (
|
||||
<p className="profile-question-help">{question.help_text}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-question-answer">{renderField(question)}</div>
|
||||
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
|
||||
<p className="profile-question-lock-note">
|
||||
This field can only be changed by an admin.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredQuestions.length > pageSize && (
|
||||
<div className="profile-questions-pagination">
|
||||
<span className="profile-questions-page-copy">
|
||||
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
|
||||
</span>
|
||||
<div className="profile-questions-pager-buttons">
|
||||
<button className="btn btn-secondary profile-questions-pager-button" disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
||||
Previous
|
||||
</button>
|
||||
<button className="btn btn-secondary profile-questions-pager-button" disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-questions-actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileQuestionsForm;
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||
import EmailTemplateManagement from './EmailTemplateManagement';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useConfirm } from '../contexts/ConfirmContext';
|
||||
import { formatLondonDate } from '../utils/timezone';
|
||||
|
||||
interface SuperAdminMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
const toast = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
|
||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -26,7 +31,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
setTiers(tierData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tiers:', error);
|
||||
alert('Failed to load membership tiers');
|
||||
toast.error('Failed to load membership tiers.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -38,7 +43,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
setShowCreateForm(false);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to create tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to create tier.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,12 +53,18 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
setEditingTier(null);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to update tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to update tier.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTier = async (tierId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete membership tier',
|
||||
message: 'Are you sure you want to delete this membership tier? This action cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +72,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
await membershipService.deleteTier(tierId);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to delete tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete tier.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,101 +174,77 @@ interface TierManagementProps {
|
||||
onCancelEdit: () => void;
|
||||
}
|
||||
|
||||
const TierManagement: React.FC<TierManagementProps> = ({
|
||||
export const TierManagement: React.FC<TierManagementProps> = ({
|
||||
tiers,
|
||||
loading,
|
||||
showCreateForm,
|
||||
editingTier,
|
||||
onCreateTier,
|
||||
onUpdateTier,
|
||||
onDeleteTier,
|
||||
onShowCreateForm,
|
||||
onHideCreateForm,
|
||||
onEditTier,
|
||||
onCancelEdit
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-loading">Loading tiers...</div>;
|
||||
return <div className="admin-empty">Loading tiers...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h4 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h4>
|
||||
<button
|
||||
onClick={onShowCreateForm}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '14px', padding: '8px 16px' }}
|
||||
>
|
||||
Create New Tier
|
||||
</button>
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<h3>Membership Tiers</h3>
|
||||
<p>Manage pricing, availability, and the copy members see when choosing a plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<TierForm
|
||||
onSubmit={onCreateTier}
|
||||
onCancel={onHideCreateForm}
|
||||
title="Create New Membership Tier"
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTier && (
|
||||
<TierForm
|
||||
initialData={editingTier}
|
||||
onSubmit={(data) => onUpdateTier(editingTier.id, data)}
|
||||
onCancel={onCancelEdit}
|
||||
title="Edit Membership Tier"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }} className="super-admin-table">
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Description</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Annual Fee</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Benefits</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Annual Fee</th>
|
||||
<th>Benefits</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tiers.map(tier => (
|
||||
<tr key={tier.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '12px', fontWeight: 'bold' }}>{tier.name}</td>
|
||||
<td style={{ padding: '12px', maxWidth: '200px' }}>
|
||||
{tier.description || 'No description'}
|
||||
<tr key={tier.id}>
|
||||
<td>
|
||||
<strong>{tier.name}</strong>
|
||||
<span className="muted-line">Created {formatLondonDate(tier.created_at)}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>£{tier.annual_fee.toFixed(2)}</td>
|
||||
<td style={{ padding: '12px', maxWidth: '250px' }}>
|
||||
{tier.benefits || 'No benefits listed'}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td>{tier.description || 'No description'}</td>
|
||||
<td>£{tier.annual_fee.toFixed(2)}</td>
|
||||
<td className="admin-tier-benefits-cell">{tier.benefits || 'No benefits listed'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${tier.is_active ? 'status-active' : 'status-expired'}`}>
|
||||
{tier.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td>
|
||||
<div className="table-button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditTier(tier)}
|
||||
className="action-btn"
|
||||
style={{ marginRight: '8px', color: 'white', backgroundColor: '#007bff', border: '1px solid #007bff' }}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteTier(tier.id)}
|
||||
className="action-btn action-btn-danger"
|
||||
style={{ color: 'white', backgroundColor: '#dc3545', border: '1px solid #dc3545' }}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tiers.length === 0 && <p className="admin-empty admin-table-empty">No membership tiers found.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -268,9 +255,10 @@ interface TierFormProps {
|
||||
onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
|
||||
onCancel: () => void;
|
||||
title: string;
|
||||
variant?: 'inline' | 'rail' | 'drawer';
|
||||
}
|
||||
|
||||
const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, title }) => {
|
||||
export const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, title, variant = 'inline' }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
@@ -288,18 +276,21 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const panelClassName =
|
||||
variant === 'rail'
|
||||
? 'admin-rail-form-panel'
|
||||
: variant === 'drawer'
|
||||
? 'admin-drawer-form-panel'
|
||||
: 'admin-inline-form-panel';
|
||||
|
||||
const gridClassName = variant === 'inline' ? 'admin-inline-form-grid' : 'admin-rail-form-grid';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>{title}</h4>
|
||||
<div className={panelClassName}>
|
||||
<h4>{title}</h4>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div className={gridClassName}>
|
||||
<div className="modal-form-group">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
@@ -323,7 +314,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-form-group" style={{ marginBottom: '16px' }}>
|
||||
<div className="modal-form-group">
|
||||
<label>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -333,28 +324,19 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-form-group" style={{ marginBottom: '16px' }}>
|
||||
<div className="modal-form-group">
|
||||
<label>Benefits</label>
|
||||
<textarea
|
||||
value={formData.benefits}
|
||||
onChange={(e) => handleChange('benefits', e.target.value)}
|
||||
placeholder="List the benefits of this membership tier"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
backgroundColor: '#fff',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
className="admin-inline-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div className="admin-inline-toggle-row">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
@@ -364,7 +346,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<div className="modal-buttons">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
@@ -384,4 +366,4 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
);
|
||||
};
|
||||
|
||||
export default SuperAdminMenu;
|
||||
export default SuperAdminMenu;
|
||||
|
||||
+1995
-943
File diff suppressed because it is too large
Load Diff
@@ -26,48 +26,70 @@ const ForgotPassword: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h2>Forgot Password</h2>
|
||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
<div className="auth-shell">
|
||||
<header className="auth-topbar">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-mark">S</div>
|
||||
<div className="portal-brand-text">
|
||||
<h1>SASA Member Portal</h1>
|
||||
<div className="portal-subtitle">Account recovery</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{message && <div className="alert alert-success">{message}</div>}
|
||||
<main className="auth-container">
|
||||
<section className="auth-welcome-card">
|
||||
<div className="auth-kicker">Password Help</div>
|
||||
<h2>Recover access quickly</h2>
|
||||
<p>
|
||||
Enter the email address tied to your account and we'll send a secure password reset link if that account exists.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
<section className="auth-card">
|
||||
<div className="auth-card-head">
|
||||
<h2>Forgot Password</h2>
|
||||
<span>Email reset link</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', marginTop: '16px' }}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="auth-card-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{message && <div className="alert alert-success">{message}</div>}
|
||||
|
||||
<div className="form-footer">
|
||||
<Link to="/login" style={{ color: '#0066cc', textDecoration: 'none' }}>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary auth-submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="auth-footer">
|
||||
<div>
|
||||
<Link to="/login">Back to login</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
export default ForgotPassword;
|
||||
|
||||
@@ -43,84 +43,89 @@ const Login: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container" style={{ gap: '40px', padding: '20px' }}>
|
||||
<div className="welcome-section" style={{
|
||||
flex: '1',
|
||||
maxWidth: '400px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
padding: '30px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h1 style={{ color: '#333', marginBottom: '16px', fontSize: '2.2rem' }}>Welcome to SASA</h1>
|
||||
<p style={{ fontSize: '1.1rem', color: '#666', lineHeight: '1.6', marginBottom: '20px' }}>
|
||||
REPLACE WITH BOB WORDS: Swansea Airport Stakeholder's Association (SASA) is a community interest company run by volunteers, which holds the lease of Swansea Airport.
|
||||
</p>
|
||||
<p style={{ fontSize: '1rem', color: '#555', lineHeight: '1.5' }}>
|
||||
Join our community of aviation enthusiasts and support the future of Swansea Airport.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-card" style={{ flex: '1', maxWidth: '400px' }}>
|
||||
<h2>SASA Member Portal</h2>
|
||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
||||
Log in to your membership account
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<div className="auth-shell">
|
||||
<header className="auth-topbar">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-mark">S</div>
|
||||
<div className="portal-brand-text">
|
||||
<h1>SASA Member Portal</h1>
|
||||
<div className="portal-subtitle">Member access and admin control room</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', marginTop: '16px' }}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Log In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="form-footer">
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Link to="/forgot-password" style={{ color: '#0066cc', textDecoration: 'none' }}>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate('/register')}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Join SASA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="auth-container">
|
||||
<section className="auth-welcome-card">
|
||||
<div className="auth-kicker">Community Access</div>
|
||||
<h2>Welcome to SASA</h2>
|
||||
<p>
|
||||
Swansea Airport Stakeholder's Association manages member access, events, and operations from one shared platform.
|
||||
</p>
|
||||
<div className="auth-feature-list">
|
||||
<div className="auth-feature-item">Manage your membership, payments, and events in one place</div>
|
||||
<div className="auth-feature-item">Keep profile and contact details current without admin help</div>
|
||||
<div className="auth-feature-item">Admin users can switch into a separate operations workspace after login</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="auth-card">
|
||||
<div className="auth-card-head">
|
||||
<h2>Sign In</h2>
|
||||
<span>Secure session</span>
|
||||
</div>
|
||||
|
||||
<div className="auth-card-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary auth-submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Signing In...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="form-footer auth-footer">
|
||||
<div>
|
||||
<Link to="/forgot-password">Forgot your password?</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary auth-submit"
|
||||
onClick={() => navigate('/register')}
|
||||
>
|
||||
Join SASA
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useConfirm } from '../contexts/ConfirmContext';
|
||||
|
||||
const MembershipTiers: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -20,7 +24,7 @@ const MembershipTiers: React.FC = () => {
|
||||
setTiers(tierData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tiers:', error);
|
||||
alert('Failed to load membership tiers');
|
||||
toast.error('Failed to load membership tiers.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -32,7 +36,7 @@ const MembershipTiers: React.FC = () => {
|
||||
setShowCreateForm(false);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to create tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to create tier.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,12 +46,18 @@ const MembershipTiers: React.FC = () => {
|
||||
setEditingTier(null);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to update tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to update tier.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTier = async (tierId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete membership tier',
|
||||
message: 'Are you sure you want to delete this membership tier? This action cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +65,7 @@ const MembershipTiers: React.FC = () => {
|
||||
await membershipService.deleteTier(tierId);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to delete tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete tier.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -393,4 +403,4 @@ const MembershipTierForm: React.FC<MembershipTierFormProps> = ({ tier, onSave, o
|
||||
);
|
||||
};
|
||||
|
||||
export default MembershipTiers;
|
||||
export default MembershipTiers;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const PrivacyPolicy: React.FC = () => {
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
|
||||
<div className="card">
|
||||
<h2 style={{ marginBottom: '12px' }}>Privacy Policy</h2>
|
||||
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
|
||||
Privacy policy content will be added here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicy;
|
||||
+157
-135
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { authService, RegisterData } from '../services/membershipService';
|
||||
|
||||
const Register: React.FC = () => {
|
||||
@@ -67,142 +67,164 @@ const Register: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h2>Create Your Account</h2>
|
||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
||||
Join Swansea Airport Stakeholders Alliance
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '40px', maxWidth: '900px', margin: '0 auto' }}>
|
||||
{/* Left Column - Personal Information */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="first_name">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="last_name">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handlePasswordChange}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '12px' }}>
|
||||
Minimum 8 characters
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={handlePasswordChange}
|
||||
minLength={8}
|
||||
required
|
||||
style={{
|
||||
borderColor: confirmPassword && !passwordsMatch ? '#dc3545' : confirmPassword && passwordsMatch ? '#28a745' : undefined
|
||||
}}
|
||||
/>
|
||||
{confirmPassword && (
|
||||
<small style={{
|
||||
color: passwordsMatch ? '#28a745' : '#dc3545',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{passwordsMatch ? '✓ Passwords match' : '✗ Passwords do not match'}
|
||||
</small>
|
||||
)}
|
||||
{!confirmPassword && (
|
||||
<small style={{ color: '#666', fontSize: '12px' }}>
|
||||
Re-enter your password
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="auth-shell">
|
||||
<header className="auth-topbar">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-mark">S</div>
|
||||
<div className="portal-brand-text">
|
||||
<h1>SASA Member Portal</h1>
|
||||
<div className="portal-subtitle">Membership registration and profile setup</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Contact Information */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="phone">Phone (optional)</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="address">Address (optional)</label>
|
||||
<textarea
|
||||
id="address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button - Full Width */}
|
||||
<div style={{ gridColumn: '1 / -1', marginTop: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Create Account & Sign In'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="form-footer">
|
||||
Already have an account? <a href="/login">Log in</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="auth-container auth-container-wide">
|
||||
<section className="auth-welcome-card">
|
||||
<div className="auth-kicker">New Membership</div>
|
||||
<h2>Join the SASA community</h2>
|
||||
<p>
|
||||
Create your account to manage your membership, respond to events, and keep your contact details up to date.
|
||||
</p>
|
||||
<div className="auth-feature-list">
|
||||
<div className="auth-feature-item">Straightforward onboarding with automatic sign-in</div>
|
||||
<div className="auth-feature-item">Membership tiers, payments, and event RSVPs in one place</div>
|
||||
<div className="auth-feature-item">A separate admin workspace for staff users after login</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="auth-card auth-card-wide">
|
||||
<div className="auth-card-head">
|
||||
<h2>Create Account</h2>
|
||||
<span>Step 1 of 1</span>
|
||||
</div>
|
||||
|
||||
<div className="auth-card-body">
|
||||
<p className="auth-card-copy">
|
||||
Complete the essentials below. You can add or update the rest of your profile later from your dashboard.
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="first_name">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
autoComplete="given-name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="last_name">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
autoComplete="family-name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="phone">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handlePasswordChange}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<small className="form-hint">Minimum 8 characters.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={handlePasswordChange}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
className={confirmPassword ? (passwordsMatch ? 'field-success' : 'field-error') : ''}
|
||||
required
|
||||
/>
|
||||
{confirmPassword ? (
|
||||
<small className={passwordsMatch ? 'form-hint hint-success' : 'form-hint hint-error'}>
|
||||
{passwordsMatch ? 'Passwords match.' : 'Passwords do not match.'}
|
||||
</small>
|
||||
) : (
|
||||
<small className="form-hint">Re-enter your password to confirm it.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group-full">
|
||||
<label htmlFor="address">Address</label>
|
||||
<textarea
|
||||
id="address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group-full">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary auth-submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Create Account & Sign In'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="auth-footer">
|
||||
<div>
|
||||
Already have an account? <Link to="/login">Log in</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,74 +55,119 @@ const ResetPassword: React.FC = () => {
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h2>Invalid Reset Link</h2>
|
||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
||||
This password reset link is invalid or has expired. Please request a new password reset.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Request New Reset Link
|
||||
</button>
|
||||
</div>
|
||||
<div className="auth-shell">
|
||||
<header className="auth-topbar">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-mark">S</div>
|
||||
<div className="portal-brand-text">
|
||||
<h1>SASA Member Portal</h1>
|
||||
<div className="portal-subtitle">Account recovery</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="auth-container">
|
||||
<section className="auth-welcome-card">
|
||||
<div className="auth-kicker">Link Expired</div>
|
||||
<h2>This reset link can’t be used</h2>
|
||||
<p>
|
||||
The link is missing or no longer valid. Request a fresh reset email and try again from the newest message.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="auth-card">
|
||||
<div className="auth-card-head">
|
||||
<h2>Invalid Reset Link</h2>
|
||||
<span>Request a new one</span>
|
||||
</div>
|
||||
|
||||
<div className="auth-card-body">
|
||||
<button
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
className="btn btn-primary auth-submit"
|
||||
>
|
||||
Request New Reset Link
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h2>Reset Password</h2>
|
||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
||||
Enter your new password below. Make sure it's at least 8 characters long.
|
||||
</p>
|
||||
<div className="auth-shell">
|
||||
<header className="auth-topbar">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-mark">S</div>
|
||||
<div className="portal-brand-text">
|
||||
<h1>SASA Member Portal</h1>
|
||||
<div className="portal-subtitle">Choose a new password</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{message && <div className="alert alert-success">{message}</div>}
|
||||
<main className="auth-container">
|
||||
<section className="auth-welcome-card">
|
||||
<div className="auth-kicker">Secure Reset</div>
|
||||
<h2>Set a fresh password</h2>
|
||||
<p>
|
||||
Use a password with at least 8 characters. After a successful reset, you'll be returned to the login screen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="newPassword">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<section className="auth-card">
|
||||
<div className="auth-card-head">
|
||||
<h2>Reset Password</h2>
|
||||
<span>Secure update</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-card-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{message && <div className="alert alert-success">{message}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', marginTop: '16px' }}
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="newPassword">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary auth-submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
export default ResetPassword;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const TermsOfService: React.FC = () => {
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
|
||||
<div className="card">
|
||||
<h2 style={{ marginBottom: '12px' }}>Terms of Service</h2>
|
||||
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
|
||||
Terms of service content will be added here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsOfService;
|
||||
@@ -1,4 +1,5 @@
|
||||
import api from './api';
|
||||
import { ensureUtcIso } from '../utils/timezone';
|
||||
|
||||
export interface RegisterData {
|
||||
email: string;
|
||||
@@ -26,11 +27,62 @@ export interface User {
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
role: string;
|
||||
volunteer_level: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
export type ProfileQuestionInputType = 'text' | 'number' | 'boolean' | 'date' | 'select';
|
||||
|
||||
export interface ProfileQuestionOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProfileQuestion {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
help_text: string | null;
|
||||
input_type: ProfileQuestionInputType;
|
||||
placeholder: string | null;
|
||||
options: ProfileQuestionOption[];
|
||||
is_required: boolean;
|
||||
is_active: boolean;
|
||||
admin_only_edit: boolean;
|
||||
display_order: number;
|
||||
depends_on_question_id: number | null;
|
||||
depends_on_value: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProfileQuestionForUser extends ProfileQuestion {
|
||||
answer: string | number | boolean | null;
|
||||
can_edit: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileQuestionUpsertData {
|
||||
key: string;
|
||||
label: string;
|
||||
help_text?: string | null;
|
||||
input_type: ProfileQuestionInputType;
|
||||
placeholder?: string | null;
|
||||
options?: ProfileQuestionOption[] | null;
|
||||
is_required?: boolean;
|
||||
is_active?: boolean;
|
||||
admin_only_edit?: boolean;
|
||||
display_order?: number;
|
||||
depends_on_question_id?: number | null;
|
||||
depends_on_value?: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileAnswerInput {
|
||||
question_id: number;
|
||||
value: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface MembershipTier {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -169,6 +221,127 @@ export interface EventRSVPData {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type EspReaderType = 'checkin_checkout';
|
||||
export type EspReaderProvisioningStatus = 'pending' | 'approved' | 'provisioned' | 'rejected';
|
||||
export type EspTapAction = 'check_in' | 'check_out' | 'denied' | 'unknown';
|
||||
export type RfidWriteJobStatus = 'pending' | 'claimed' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface EspReader {
|
||||
id: number;
|
||||
device_id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
reader_type: EspReaderType;
|
||||
provisioning_status: EspReaderProvisioningStatus;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
can_write_cards: boolean;
|
||||
firmware_version: string | null;
|
||||
last_seen_at: string | null;
|
||||
approved_at: string | null;
|
||||
provisioned_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
api_key?: string;
|
||||
}
|
||||
|
||||
export interface EspReaderCreateData {
|
||||
device_id: string;
|
||||
name: string;
|
||||
location?: string | null;
|
||||
reader_type?: EspReaderType;
|
||||
notes?: string | null;
|
||||
is_active?: boolean;
|
||||
can_write_cards?: boolean;
|
||||
firmware_version?: string | null;
|
||||
api_key?: string;
|
||||
}
|
||||
|
||||
export interface EspReaderUpdateData {
|
||||
name?: string;
|
||||
location?: string | null;
|
||||
reader_type?: EspReaderType;
|
||||
notes?: string | null;
|
||||
is_active?: boolean;
|
||||
can_write_cards?: boolean;
|
||||
rotate_api_key?: boolean;
|
||||
}
|
||||
|
||||
export interface RfidCard {
|
||||
id: number;
|
||||
uid: string;
|
||||
user_id: number | null;
|
||||
label: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RfidCardCreateData {
|
||||
uid: string;
|
||||
user_id?: number | null;
|
||||
label?: string | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface RfidCardUpdateData {
|
||||
user_id?: number | null;
|
||||
label?: string | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface RfidTap {
|
||||
id: number;
|
||||
reader_id: number;
|
||||
card_id: number | null;
|
||||
user_id: number | null;
|
||||
card_uid: string;
|
||||
action: EspTapAction;
|
||||
accepted: boolean;
|
||||
message: string | null;
|
||||
tapped_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AttendanceSession {
|
||||
id: number;
|
||||
user_id: number;
|
||||
reader_id: number;
|
||||
check_in_tap_id: number;
|
||||
check_out_tap_id: number | null;
|
||||
checked_in_at: string;
|
||||
checked_out_at: string | null;
|
||||
checkout_source: string | null;
|
||||
system_flag_reason: string | null;
|
||||
duration_seconds: number | null;
|
||||
is_open: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RfidWriteJob {
|
||||
id: number;
|
||||
reader_id: number;
|
||||
user_id: number;
|
||||
card_id: number | null;
|
||||
label: string;
|
||||
status: RfidWriteJobStatus;
|
||||
requested_by_user_id: number;
|
||||
card_uid: string | null;
|
||||
write_payload: string | null;
|
||||
claimed_at: string | null;
|
||||
completed_at: string | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RfidWriteJobCreateData {
|
||||
reader_id: number;
|
||||
user_id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
async register(data: RegisterData) {
|
||||
const response = await api.post('/auth/register', data);
|
||||
@@ -230,6 +403,51 @@ export const userService = {
|
||||
const response = await api.delete(`/users/${userId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getMyProfileQuestions(): Promise<ProfileQuestionForUser[]> {
|
||||
const response = await api.get('/users/me/profile-questions');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateMyProfileAnswers(answers: ProfileAnswerInput[]): Promise<{ message: string }> {
|
||||
const response = await api.put('/users/me/profile-answers', { answers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getAdminProfileQuestions(includeInactive: boolean = true): Promise<ProfileQuestion[]> {
|
||||
const response = await api.get(`/users/admin/profile-questions?include_inactive=${includeInactive}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createAdminProfileQuestion(data: ProfileQuestionUpsertData): Promise<ProfileQuestion> {
|
||||
const response = await api.post('/users/admin/profile-questions', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateAdminProfileQuestion(questionId: number, data: Partial<ProfileQuestionUpsertData>): Promise<ProfileQuestion> {
|
||||
const response = await api.put(`/users/admin/profile-questions/${questionId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deactivateAdminProfileQuestion(questionId: number): Promise<{ message: string }> {
|
||||
const response = await api.delete(`/users/admin/profile-questions/${questionId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getUserProfileAnswers(userId: number): Promise<ProfileQuestionForUser[]> {
|
||||
const response = await api.get(`/users/admin/users/${userId}/profile-answers`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateUserProfileAnswers(userId: number, answers: ProfileAnswerInput[]): Promise<{ message: string }> {
|
||||
const response = await api.put(`/users/admin/users/${userId}/profile-answers`, { answers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async sendUserPasswordReset(userId: number): Promise<{ message: string }> {
|
||||
const response = await api.post(`/users/${userId}/send-password-reset`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const membershipService = {
|
||||
@@ -313,12 +531,18 @@ export const eventService = {
|
||||
},
|
||||
|
||||
async createEvent(data: EventCreateData): Promise<Event> {
|
||||
const response = await api.post('/events/', data);
|
||||
const response = await api.post('/events/', {
|
||||
...data,
|
||||
event_date: ensureUtcIso(data.event_date)
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateEvent(eventId: number, data: EventUpdateData): Promise<Event> {
|
||||
const response = await api.put(`/events/${eventId}`, data);
|
||||
const response = await api.put(`/events/${eventId}`, {
|
||||
...data,
|
||||
event_date: data.event_date ? ensureUtcIso(data.event_date) : undefined
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -342,3 +566,80 @@ export const eventService = {
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const espService = {
|
||||
async getReaders(includeInactive: boolean = true): Promise<EspReader[]> {
|
||||
const response = await api.get(`/esp/admin/readers?include_inactive=${includeInactive}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createReader(data: EspReaderCreateData): Promise<EspReader> {
|
||||
const response = await api.post('/esp/admin/readers', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateReader(readerId: number, data: EspReaderUpdateData): Promise<EspReader> {
|
||||
const response = await api.put(`/esp/admin/readers/${readerId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async approveReader(readerId: number): Promise<EspReader> {
|
||||
const response = await api.post(`/esp/admin/readers/${readerId}/approve`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async rejectReader(readerId: number): Promise<EspReader> {
|
||||
const response = await api.post(`/esp/admin/readers/${readerId}/reject`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteReader(readerId: number): Promise<{ message: string }> {
|
||||
const response = await api.delete(`/esp/admin/readers/${readerId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getCards(includeInactive: boolean = true): Promise<RfidCard[]> {
|
||||
const response = await api.get(`/esp/admin/cards?include_inactive=${includeInactive}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createCard(data: RfidCardCreateData): Promise<RfidCard> {
|
||||
const response = await api.post('/esp/admin/cards', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateCard(cardId: number, data: RfidCardUpdateData): Promise<RfidCard> {
|
||||
const response = await api.put(`/esp/admin/cards/${cardId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getTaps(limit: number = 100): Promise<RfidTap[]> {
|
||||
const response = await api.get(`/esp/admin/taps?limit=${limit}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getAttendance(openOnly: boolean = false, limit: number = 100): Promise<AttendanceSession[]> {
|
||||
const response = await api.get(`/esp/admin/attendance?open_only=${openOnly}&limit=${limit}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async closeStaleSessions(checkoutHour: number = 17): Promise<{ closed_count: number }> {
|
||||
const response = await api.post('/esp/admin/attendance/close-stale', { checkout_hour: checkoutHour });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getWriteJobs(limit: number = 100): Promise<RfidWriteJob[]> {
|
||||
const response = await api.get(`/esp/admin/write-jobs?limit=${limit}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async queueWriteJob(data: RfidWriteJobCreateData): Promise<RfidWriteJob> {
|
||||
const response = await api.post('/esp/admin/write-jobs', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async cancelWriteJob(jobId: number): Promise<RfidWriteJob> {
|
||||
const response = await api.post(`/esp/admin/write-jobs/${jobId}/cancel`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canEditProfileQuestion,
|
||||
DependentProfileQuestion,
|
||||
isProfileQuestionVisible,
|
||||
ProfileQuestionAnswerValue
|
||||
} from './profileQuestionLogic';
|
||||
|
||||
describe('profile question logic', () => {
|
||||
it('keeps admin-managed questions read-only outside admin editing mode', () => {
|
||||
const question = {
|
||||
id: 1,
|
||||
admin_only_edit: true,
|
||||
can_edit: true
|
||||
};
|
||||
|
||||
expect(canEditProfileQuestion(question, false)).toBe(false);
|
||||
expect(canEditProfileQuestion(question, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not allow editing when the API marks a question read-only', () => {
|
||||
expect(canEditProfileQuestion({ id: 1, admin_only_edit: false, can_edit: false }, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows dependent questions when boolean answers match', () => {
|
||||
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
|
||||
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'true' };
|
||||
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
|
||||
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: true };
|
||||
|
||||
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides dependent questions when select answers do not match', () => {
|
||||
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
|
||||
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'completed' };
|
||||
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
|
||||
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: 'pending' };
|
||||
|
||||
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export type ProfileQuestionAnswerValue = string | number | boolean | null;
|
||||
|
||||
export interface EditableProfileQuestion {
|
||||
id: number;
|
||||
admin_only_edit: boolean;
|
||||
can_edit: boolean;
|
||||
}
|
||||
|
||||
export interface DependentProfileQuestion {
|
||||
id: number;
|
||||
depends_on_question_id: number | null;
|
||||
depends_on_value: string | null;
|
||||
}
|
||||
|
||||
export const answerToComparable = (value: ProfileQuestionAnswerValue): string | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const canEditProfileQuestion = (
|
||||
question: EditableProfileQuestion,
|
||||
allowAdminManagedEdit = false
|
||||
): boolean => {
|
||||
if (allowAdminManagedEdit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!question.can_edit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (question.admin_only_edit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isProfileQuestionVisible = <TQuestion extends DependentProfileQuestion>(
|
||||
question: TQuestion,
|
||||
questionsById: Map<number, TQuestion>,
|
||||
answers: Record<number, ProfileQuestionAnswerValue>
|
||||
): boolean => {
|
||||
if (!question.depends_on_question_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentQuestion = questionsById.get(question.depends_on_question_id);
|
||||
if (!parentQuestion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentAnswer = answerToComparable(answers[parentQuestion.id] ?? null);
|
||||
if (question.depends_on_value === null || question.depends_on_value === undefined) {
|
||||
return parentAnswer !== null && parentAnswer !== '';
|
||||
}
|
||||
|
||||
return parentAnswer === question.depends_on_value;
|
||||
};
|
||||
@@ -1,6 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const hmrConfig = process.env.VITE_HMR_CLIENT_PORT || process.env.VITE_HMR_PROTOCOL || process.env.VITE_HMR_HOST
|
||||
? {
|
||||
clientPort: process.env.VITE_HMR_CLIENT_PORT ? Number(process.env.VITE_HMR_CLIENT_PORT) : undefined,
|
||||
protocol: process.env.VITE_HMR_PROTOCOL,
|
||||
host: process.env.VITE_HMR_HOST
|
||||
}
|
||||
: undefined
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
@@ -11,9 +19,7 @@ export default defineConfig({
|
||||
watch: {
|
||||
usePolling: true
|
||||
},
|
||||
hmr: {
|
||||
clientPort: 8050
|
||||
},
|
||||
hmr: hmrConfig,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
docker compose build
|
||||
docker compose run --rm frontend npm test
|
||||
docker compose run --rm backend pytest -q
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
Reference in New Issue
Block a user