Compare commits
7 Commits
main
..
e0b3a6926e
| Author | SHA1 | Date | |
|---|---|---|---|
| e0b3a6926e | |||
| 000555dbd7 | |||
| f068a863e0 | |||
| d024bf7fa3 | |||
| 34489fd7b7 | |||
| 1a0b4dc25d | |||
| 632e66e21d |
@@ -6,6 +6,7 @@ __pycache__/
|
|||||||
.Python
|
.Python
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
ENV/
|
ENV/
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
|
|||||||
+44
-12
@@ -4,25 +4,36 @@
|
|||||||
|
|
||||||
This project aims to develop a comprehensive membership management system for the Swansea Airport Stakeholders' Alliance. The system will handle member registration, payment processing, membership tracking, and administrative functions for a medium-sized alliance.
|
This project aims to develop a comprehensive membership management system for the Swansea Airport Stakeholders' Alliance. The system will handle member registration, payment processing, membership tracking, and administrative functions for a medium-sized alliance.
|
||||||
|
|
||||||
|
## Current Implementation Status
|
||||||
|
|
||||||
|
The app now includes a FastAPI backend, React/Vite frontend, Docker Compose development gateway, Alembic migrations, Square payment integration, SMTP2GO email integration, ESP RFID provisioning/time-sync/tap tracking, event/RSVP endpoints, configurable profile questions, privacy/terms pages, feature flags, UTC backend timestamps with London-facing frontend formatting, and a fast test gate in `restart.sh`.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
### Public Member Features
|
### Public Member Features
|
||||||
- **Self-Service Registration**: Members can sign up online and select their membership tier
|
- **Self-Service Registration**: Members can sign up online and select their membership tier
|
||||||
- **Payment Processing**: Integration with Square payment system for secure online payments, and a dummy payment system for initial testing
|
- **Payment Processing**: Integration with Square payment system for secure online payments, and a dummy payment system for initial testing
|
||||||
- **Membership Portal**: Secure login to view membership status, payment history, and upcoming meetings
|
- **Membership Portal**: Secure login to view membership status, payment history, and upcoming meetings
|
||||||
- **Renewal Reminders**: Automated email notifications for membership renewal deadlines
|
- **Profile Questions**: Members can answer configurable profile questions, including conditional and volunteering-related questions
|
||||||
- **Event Management**: View upcoming events and RSVP to participate
|
- **Event Management**: View upcoming events and RSVP to participate
|
||||||
- **Volunteering**: View assigned volunteer roles, schedule availability for roles, and access certificates/training records
|
- **Account Management**: Members can update profile details, change passwords, request password resets, and review privacy/terms pages
|
||||||
|
- **Renewal Reminders**: Planned automated email notifications for membership renewal deadlines
|
||||||
|
- **Volunteering**: Volunteer-related profile fields are implemented; richer role, schedule, and certificate screens are planned
|
||||||
|
|
||||||
### Administrative Features
|
### Administrative Features
|
||||||
- **Member Database Management**: Query and modify member records
|
- **Member Database Management**: Query and modify member records
|
||||||
- **Manual Payment Entry**: Record cash payments to activate memberships
|
- **Manual Payment Entry**: Record cash payments to activate memberships
|
||||||
- **Membership Tier Management**: Configure different membership levels and associated fees
|
- **Membership Tier Management**: Configure different membership levels and associated fees
|
||||||
- **Meeting Management**: Post notices and updates about upcoming alliance meetings
|
- **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions
|
||||||
- **Reporting**: Generate reports on membership statistics and payment status
|
- **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance
|
||||||
- **Files**: A repositry for files which members can access based on their tier - such as meeting minutes and manuals. Admins can upload files to this area.
|
- **Timezone Handling**: Persist timestamps in UTC, display member-facing times in Europe/London, and convert event input back to UTC before saving
|
||||||
|
- **ESP RFID Readers**: Reader registration, time sync, taps, attendance sessions, and queued card-writing jobs are implemented end to end
|
||||||
|
- **Email Management**: Edit database-backed email templates with escaped previews, send test emails, and monitor SMTP2GO bounces
|
||||||
|
- **Feature Flags**: View backend feature flags and reload them from the super-admin interface
|
||||||
|
- **Reporting**: Planned reports on membership statistics and payment status
|
||||||
|
- **Files**: Planned repository for member files based on tier, such as meeting minutes and manuals
|
||||||
- **Event Management**: Create, edit, and manage events, track RSVPs and attendance
|
- **Event Management**: Create, edit, and manage events, track RSVPs and attendance
|
||||||
- **Volunteering**: Assign configurable volunteer roles to members (e.g., Fire, Radio, General), manage volunteer schedules, and record certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members.
|
- **Volunteering**: Models exist for configurable volunteer roles, assignments, schedules, and certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members.
|
||||||
|
|
||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
@@ -32,7 +43,8 @@ This project aims to develop a comprehensive membership management system for th
|
|||||||
- **Authentication**: JWT-based authentication system
|
- **Authentication**: JWT-based authentication system
|
||||||
- **Payment Integration**: Square API for payment processing
|
- **Payment Integration**: Square API for payment processing
|
||||||
- **Email Service**: SMTP2GO API for automated reminders and notifications
|
- **Email Service**: SMTP2GO API for automated reminders and notifications
|
||||||
- **Frontend**: Modern web interface (to be determined - potentially React/Vue.js)
|
- **Frontend**: React 18, TypeScript, Vite, and Tailwind CSS
|
||||||
|
- **Testing**: Vitest for frontend unit tests and pytest for backend unit tests
|
||||||
|
|
||||||
## Membership Tiers
|
## Membership Tiers
|
||||||
|
|
||||||
@@ -73,21 +85,41 @@ Each tier will have associated annual fees and benefits.
|
|||||||
- `memberships`: Membership records with tier and status
|
- `memberships`: Membership records with tier and status
|
||||||
- `payments`: Payment transactions
|
- `payments`: Payment transactions
|
||||||
- `tiers`: Membership tier definitions
|
- `tiers`: Membership tier definitions
|
||||||
|
- `profile_questions`: Configurable profile/onboarding questions
|
||||||
|
- `user_profile_answers`: Per-member profile answers
|
||||||
- `events`: Event information and details
|
- `events`: Event information and details
|
||||||
- `event_rsvps`: Event registration and attendance tracking
|
- `event_rsvps`: Event registration and attendance tracking
|
||||||
- `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General)
|
- `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General)
|
||||||
- `volunteer_assignments`: Member-to-role assignments
|
- `volunteer_assignments`: Member-to-role assignments
|
||||||
- `volunteer_schedules`: Volunteer shift scheduling and availability
|
- `volunteer_schedules`: Volunteer shift scheduling and availability
|
||||||
- `certificates`: Training certificates and qualifications
|
- `certificates`: Training certificates and qualifications
|
||||||
|
- `email_templates`: Editable SMTP2GO email templates
|
||||||
|
- `email_bounces`: Bounce/complaint/unsubscribe tracking
|
||||||
|
- `password_reset_tokens`: One-time reset tokens
|
||||||
- `notifications`: Email notification logs
|
- `notifications`: Email notification logs
|
||||||
|
|
||||||
|
## Testing and Restart Workflow
|
||||||
|
|
||||||
|
`./restart.sh` rebuilds Docker images with cache, runs the fast frontend and backend unit tests, shuts down the current stack, and starts it again only if tests pass.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./restart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Individual test commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm frontend npm test
|
||||||
|
docker compose run --rm backend pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
## Development Phases
|
## Development Phases
|
||||||
|
|
||||||
1. **Phase 1**: Core API development (authentication, user management)
|
1. **Phase 1**: Core API development (authentication, user management) - implemented
|
||||||
2. **Phase 2**: Payment integration and membership management
|
2. **Phase 2**: Payment integration and membership management - implemented
|
||||||
3. **Phase 3**: Admin interface development
|
3. **Phase 3**: Admin interface development - implemented for users, tiers, payments, emails, bounces, profile questions, and feature flags
|
||||||
4. **Phase 4**: Member portal, email system, event management, and volunteering features
|
4. **Phase 4**: Member portal, email system, event management, and volunteering features - partially implemented; richer volunteer screens and renewal reminders remain
|
||||||
5. **Phase 5**: Testing, deployment, and documentation
|
5. **Phase 5**: Testing, deployment, and documentation - active; fast unit tests and documentation are in place
|
||||||
|
|
||||||
## Deployment Considerations
|
## Deployment Considerations
|
||||||
|
|
||||||
|
|||||||
+119
-76
@@ -2,115 +2,158 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
membership/
|
membership/
|
||||||
├── .env # Environment configuration (ready to use)
|
├── .env # Local environment configuration
|
||||||
├── .env.example # Template for environment variables
|
├── .env.example # Environment variable template
|
||||||
├── .gitignore # Git ignore rules
|
├── .gitignore # Git ignore rules
|
||||||
├── docker-compose.yml # Docker services configuration
|
├── docker-compose.yml # Backend, frontend, gateway, and prod frontend services
|
||||||
├── INSTRUCTIONS.md # Original project requirements
|
├── restart.sh # Build, run fast tests, and restart the app
|
||||||
├── README.md # Complete documentation
|
├── INSTRUCTIONS.md # Product requirements and roadmap context
|
||||||
├── QUICKSTART.md # Quick start guide
|
├── README.md # Full project documentation
|
||||||
|
├── QUICKSTART.md # Short operator/developer guide
|
||||||
│
|
│
|
||||||
├── backend/ # FastAPI application
|
├── backend/ # FastAPI application
|
||||||
│ ├── Dockerfile # Backend container configuration
|
│ ├── Dockerfile
|
||||||
│ ├── requirements.txt # Python dependencies
|
│ ├── requirements.txt
|
||||||
|
│ ├── alembic.ini
|
||||||
|
│ ├── alembic/ # Database migrations
|
||||||
│ └── app/
|
│ └── app/
|
||||||
│ ├── __init__.py
|
│ ├── main.py # App, CORS, health check, router registration
|
||||||
│ ├── main.py # Application entry point
|
│ ├── api/
|
||||||
│ │
|
│ │ ├── dependencies.py # Auth dependencies
|
||||||
│ ├── api/ # API endpoints
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── dependencies.py # Auth dependencies
|
|
||||||
│ │ └── v1/
|
│ │ └── v1/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── auth.py # Register, login, password reset/change
|
||||||
│ │ ├── auth.py # Registration, login
|
│ │ ├── users.py # Users, profile questions, profile answers
|
||||||
│ │ ├── users.py # User management
|
│ │ ├── tiers.py # Membership tiers
|
||||||
│ │ ├── tiers.py # Membership tiers
|
│ │ ├── memberships.py
|
||||||
│ │ ├── memberships.py # Membership management
|
│ │ ├── payments.py # Manual, Square, refund, payment history
|
||||||
│ │ └── payments.py # Payment processing
|
│ │ ├── email.py # SMTP2GO email tests and bounce webhooks
|
||||||
│ │
|
│ │ ├── email_templates.py
|
||||||
│ ├── core/ # Core functionality
|
│ │ ├── events.py # Events and RSVPs
|
||||||
│ │ ├── __init__.py
|
│ │ └── feature_flags.py
|
||||||
│ │ ├── config.py # Configuration settings
|
│ ├── core/ # Config, database, security, datetime helpers, default data
|
||||||
│ │ ├── database.py # Database connection
|
│ ├── models/ # SQLAlchemy models
|
||||||
│ │ └── security.py # Auth & password hashing
|
│ ├── schemas/ # Pydantic schemas
|
||||||
│ │
|
│ ├── services/ # Email, bounce, Square, attendance, feature flags
|
||||||
│ ├── models/ # Database models
|
│ └── tests/ # Fast backend pytest unit tests
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── models.py # SQLAlchemy models
|
|
||||||
│ │
|
|
||||||
│ ├── schemas/ # Pydantic schemas
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── schemas.py # Request/response schemas
|
|
||||||
│ │
|
|
||||||
│ ├── services/ # Business logic (placeholder)
|
|
||||||
│ └── utils/ # Utilities (placeholder)
|
|
||||||
│
|
│
|
||||||
├── database/ # Database initialization
|
├── docker/
|
||||||
│ └── init.sql # Default data & admin user
|
│ └── gateway/ # Nginx dev gateway and self-signed TLS setup
|
||||||
│
|
│
|
||||||
└── frontend/ # Frontend (placeholder for future)
|
└── frontend/ # React/Vite frontend
|
||||||
|
├── Dockerfile
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts
|
||||||
|
└── src/
|
||||||
|
├── App.tsx # Routes, footer links, cookie notice
|
||||||
|
├── components/ # Dashboard, admin, payment, email, profile UI
|
||||||
|
├── contexts/ # Feature flag context/provider
|
||||||
|
├── pages/ # Login, register, dashboard, policy pages
|
||||||
|
├── services/ # API clients
|
||||||
|
└── utils/ # Shared frontend logic and Vitest tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- **`.env`** - Environment variables (database, API keys, etc.)
|
- **`.env`** - Runtime configuration for database, auth, Square, SMTP2GO, ports, and gateway TLS.
|
||||||
- **`docker-compose.yml`** - Services: MySQL + FastAPI backend
|
- **`docker-compose.yml`** - Services for FastAPI backend, Vite frontend, Nginx gateway, and production static frontend.
|
||||||
|
- **`restart.sh`** - Rebuilds images, runs frontend/backend unit tests, and restarts the stack only if tests pass.
|
||||||
|
|
||||||
### Backend Application
|
### Backend Application
|
||||||
- **`backend/app/main.py`** - FastAPI app initialization, CORS, routes
|
- **`backend/app/main.py`** - FastAPI app initialization, CORS, startup default-data seeding, routes, and health checks.
|
||||||
- **`backend/app/core/config.py`** - Settings management
|
- **`backend/app/core/config.py`** - Settings management.
|
||||||
- **`backend/app/core/security.py`** - JWT tokens, password hashing
|
- **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions.
|
||||||
- **`backend/app/models/models.py`** - Database tables (User, Membership, Payment, etc.)
|
- **`backend/app/core/security.py`** - JWT tokens and password hashing.
|
||||||
- **`backend/app/schemas/schemas.py`** - API request/response models
|
- **`backend/app/core/datetime.py`** - UTC helpers and Zulu serialization helpers.
|
||||||
|
- **`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.
|
||||||
|
- **`backend/app/tests/test_datetime_utc.py`** - UTC normalization and serialization tests.
|
||||||
|
|
||||||
### API Endpoints (v1)
|
### Frontend Application
|
||||||
- **`auth.py`** - Register, login
|
- **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard.
|
||||||
- **`users.py`** - User profile, admin user management
|
- **`frontend/src/components/MembershipSetup.tsx`** - Membership tier selection and payment flow.
|
||||||
- **`tiers.py`** - Membership tier CRUD
|
- **`frontend/src/components/SquarePayment.tsx`** - Square Web Payments SDK form.
|
||||||
- **`memberships.py`** - Membership management
|
- **`frontend/src/components/AdminProfileQuestionManager.tsx`** - Admin profile-question configuration.
|
||||||
- **`payments.py`** - Payment processing & history
|
- **`frontend/src/components/ProfileQuestionsForm.tsx`** - Member/admin answer form with dependency handling.
|
||||||
|
- **`frontend/src/components/EmailTemplateManagement.tsx`** - Email template editing.
|
||||||
|
- **`frontend/src/components/BounceManagement.tsx`** - SMTP2GO bounce management.
|
||||||
|
- **`frontend/src/components/EspReaderManagement.tsx`** - ESP reader, card, tap, and attendance admin UI.
|
||||||
|
- **`frontend/src/utils/profileQuestionLogic.test.ts`** - Fast frontend unit tests for profile-question visibility/editability.
|
||||||
|
- **`frontend/src/utils/timezone.ts`** - Europe/London display helpers and UTC conversion utilities.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **`esp.py`** - ESP reader provisioning, time sync, tap capture, dashboard login, attendance, and queued write jobs.
|
||||||
|
- **`email_templates.py`** - Database-backed template listing, lookup, update, and default seeding.
|
||||||
|
- **`feature_flags.py`** - Public feature flag listing/lookup and super-admin-only reload.
|
||||||
|
|
||||||
## Database Models
|
## Database Models
|
||||||
|
|
||||||
Fully implemented:
|
Fully implemented:
|
||||||
- **User** - Authentication, profile, roles (member/admin/super_admin)
|
- **User** - Authentication, profile, roles, volunteer level.
|
||||||
- **MembershipTier** - Configurable tiers with fees and benefits
|
- **ProfileQuestion** - Configurable profile fields, options, dependencies, admin-only edit flags.
|
||||||
- **Membership** - User memberships with status tracking
|
- **UserProfileAnswer** - Per-user answers with update attribution.
|
||||||
- **Payment** - Payment records with multiple methods
|
- **MembershipTier** - Configurable tiers with fees and benefits.
|
||||||
- **Event** - Event management (model ready, endpoints TODO)
|
- **Membership** - User memberships with status, dates, and auto-renew flag.
|
||||||
- **EventRSVP** - Event registration (model ready, endpoints TODO)
|
- **Payment** - Payment records for Square, cash, check, and dummy methods.
|
||||||
- **VolunteerRole** - Volunteer roles (model ready, endpoints TODO)
|
- **Event** - Event management records.
|
||||||
- **VolunteerAssignment** - Role assignments (model ready, endpoints TODO)
|
- **EventRSVP** - RSVP and attendance records.
|
||||||
- **VolunteerSchedule** - Shift scheduling (model ready, endpoints TODO)
|
- **EmailTemplate** - Editable database-backed email templates.
|
||||||
- **Certificate** - Training certificates (model ready, endpoints TODO)
|
- **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking.
|
||||||
- **File** - File repository (model ready, endpoints TODO)
|
- **PasswordResetToken** - One-time password reset support.
|
||||||
- **Notification** - Email tracking (model ready, endpoints TODO)
|
- **EspReader** - Provisioned RFID readers with UTC heartbeat and time-sync data.
|
||||||
|
- **RfidTap** - UTC-normalized RFID tap records.
|
||||||
|
- **AttendanceSession** - Attendance sessions driven by RFID taps.
|
||||||
|
- **RfidCardWriteJob** - Queued RFID card write jobs.
|
||||||
|
- **VolunteerRole** - Volunteer role definitions.
|
||||||
|
- **VolunteerAssignment** - Member-to-role assignments.
|
||||||
|
- **VolunteerSchedule** - Volunteer shift schedules.
|
||||||
|
- **Certificate** - Training/certificate records.
|
||||||
|
- **File** - File repository metadata.
|
||||||
|
- **Notification** - Email notification logs.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start everything
|
# Start everything
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
docker-compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
# Access API docs
|
# Access API docs
|
||||||
# http://localhost:8000/docs
|
# http://localhost:8050/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run both fast test suites and restart only if they pass
|
||||||
|
./restart.sh
|
||||||
|
|
||||||
|
# Run test suites individually
|
||||||
|
docker compose run --rm frontend npm test
|
||||||
|
docker compose run --rm backend pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
## Default Credentials
|
## Default Credentials
|
||||||
|
|
||||||
**Admin**: admin@swanseaairport.org / admin123
|
**Admin**: admin@swanseaairport.org / admin123
|
||||||
|
|
||||||
**Database**: Configured via environment variables (see .env file)
|
**Database**: Configured via environment variables in `.env`.
|
||||||
|
|
||||||
## What's Next
|
## Remaining Roadmap
|
||||||
|
|
||||||
1. Test the API endpoints
|
1. Expand authenticated API tests for member/admin workflows
|
||||||
2. Add Square payment integration
|
2. Add member file repository endpoints and UI
|
||||||
3. Implement email notifications
|
3. Build richer volunteer assignment, schedule, and certificate screens
|
||||||
4. Create event management endpoints
|
4. Add renewal reminder batch jobs
|
||||||
5. Add volunteer management endpoints
|
5. Add reporting and analytics
|
||||||
6. Build frontend interface
|
|
||||||
|
|||||||
+45
-1
@@ -16,11 +16,27 @@ Wait until you see "Application startup complete", then press Ctrl+C.
|
|||||||
- API: http://localhost:8050/api/v1
|
- API: http://localhost:8050/api/v1
|
||||||
- Docs: http://localhost:8050/docs
|
- Docs: http://localhost:8050/docs
|
||||||
|
|
||||||
|
API datetimes are stored and returned in UTC/Zulu. The frontend shows member-facing times in Europe/London and converts event input back to UTC before saving.
|
||||||
|
|
||||||
Set `APP_PORT` in `.env` / `.env.example` to change `8050`.
|
Set `APP_PORT` in `.env` / `.env.example` to change `8050`.
|
||||||
For Square payment form testing, use HTTPS at `https://localhost:8443`.
|
For Square payment form testing, use HTTPS at `https://localhost:8443`.
|
||||||
Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`.
|
Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`.
|
||||||
TLS certs are auto-generated by the gateway container on first start.
|
TLS certs are auto-generated by the gateway container on first start.
|
||||||
|
|
||||||
|
## Restart With Tests
|
||||||
|
|
||||||
|
Use the restart helper when you want to rebuild, run the fast test suite, and restart only after tests pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./restart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs:
|
||||||
|
- `docker compose run --rm frontend npm test`
|
||||||
|
- `docker compose run --rm backend pytest -q`
|
||||||
|
|
||||||
|
The current tests cover frontend profile-question visibility/editability rules and backend profile-question answer normalization/validation. They are designed to complete quickly.
|
||||||
|
|
||||||
## Testing the API
|
## Testing the API
|
||||||
|
|
||||||
### 1. Register a new user
|
### 1. Register a new user
|
||||||
@@ -108,6 +124,32 @@ docker compose logs -f gateway
|
|||||||
1. Login as admin
|
1. Login as admin
|
||||||
2. GET `/api/v1/users/`
|
2. GET `/api/v1/users/`
|
||||||
|
|
||||||
|
### Manage profile questions (admin)
|
||||||
|
1. Login as admin or super admin
|
||||||
|
2. Open the dashboard Admin area
|
||||||
|
3. Create, edit, deactivate, and order configurable profile questions
|
||||||
|
4. Use dependencies to show questions only after a matching parent answer
|
||||||
|
|
||||||
|
### Edit member profile answers
|
||||||
|
1. Members can update normal profile questions from the Questions dashboard tab
|
||||||
|
2. Admin-only answers, such as verified training fields, must be updated by an admin
|
||||||
|
|
||||||
|
### Manage events and RSVPs
|
||||||
|
1. Admins can create and edit events from the dashboard
|
||||||
|
2. Members can view upcoming events and submit RSVP status
|
||||||
|
3. Admins can view RSVP lists and attendance data
|
||||||
|
|
||||||
|
### ESP RFID readers
|
||||||
|
1. Readers register with `/api/v1/esp/device/register`
|
||||||
|
2. Readers sync clocks from `/api/v1/esp/device/time`
|
||||||
|
3. Tap, heartbeat, and write-job timestamps are UTC-normalized in the backend
|
||||||
|
4. Admins can review readers, taps, attendance, and card-write jobs from the ESP screens
|
||||||
|
|
||||||
|
### Manage email templates and bounces
|
||||||
|
1. Super admins can edit database-backed email templates; previews are shown as escaped HTML text
|
||||||
|
2. SMTP2GO bounce webhooks are stored and visible in bounce management
|
||||||
|
3. Bounce cleanup and manual deactivation are available through the API/admin screens
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Check service status
|
### Check service status
|
||||||
@@ -143,4 +185,6 @@ docker compose up -d
|
|||||||
3. Create additional admin users
|
3. Create additional admin users
|
||||||
4. Configure membership tiers as needed
|
4. Configure membership tiers as needed
|
||||||
5. Test payment processing
|
5. Test payment processing
|
||||||
6. Customize email templates (coming soon)
|
6. Customize email templates
|
||||||
|
7. Configure profile questions for onboarding and volunteer data
|
||||||
|
8. Use `./restart.sh` before deploying changes so frontend and backend unit tests run first
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
# Swansea Airport Stakeholders' Alliance Membership Management System
|
# Swansea Airport Stakeholders' Alliance Membership Management System
|
||||||
|
|
||||||
A comprehensive membership management system built with FastAPI, MySQL, and Docker.
|
A membership management system for Swansea Airport Stakeholders' Alliance, built with FastAPI, React, MySQL-compatible storage, Square payments, SMTP2GO email services, and Docker Compose.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **User Management**: Registration, authentication, and profile management
|
- **Authentication and accounts**: Registration, JSON/form login, JWT sessions, password reset, password change, and role-based access for members, admins, and super admins.
|
||||||
- **Membership Tiers**: Configurable membership levels with different benefits and fees
|
- **Member portal**: Dashboard with membership status, payment history, membership setup, account settings, profile editing, configurable profile questions, cookie notice, privacy policy, and terms of service pages.
|
||||||
- **Payment Processing**: Support for Square payments, cash, and check payments
|
- **Admin operations**: User listing/editing, admin-triggered member password reset emails, membership tier CRUD, manual payment recording, Square refunds, email template editing with escaped previews, SMTP2GO bounce management, profile-question management, and super-admin feature-flag reloads.
|
||||||
- **Admin Dashboard**: Complete administrative control over members and payments
|
- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits.
|
||||||
- **Event Management**: Create and manage events with RSVP tracking (coming soon)
|
- **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking.
|
||||||
- **Volunteer Management**: Role assignments, scheduling, and certificates (coming soon)
|
- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility.
|
||||||
- **Email Notifications**: Automated notifications via SMTP2GO (coming soon)
|
- **Time handling**: Backend timestamps are stored and returned as UTC/Zulu, the frontend renders member-facing dates and times in Europe/London, and event entry is converted back to UTC before save.
|
||||||
|
- **ESP RFID**: Reader provisioning, heartbeat, time sync, tap capture, queued card writes, and admin review of readers/cards/attendance.
|
||||||
|
- **Volunteer and profile data**: Volunteer flag/level support, configurable member profile questions, conditional questions, admin-only answers, seeded aviation/volunteering questions, and data models for volunteer roles, assignments, schedules, and certificates.
|
||||||
|
- **Email system**: SMTP2GO-backed email sending, default database templates, editable templates, welcome/password-reset/test emails, bounce webhooks, bounce stats, cleanup, and manual deactivation.
|
||||||
|
- **Feature flags**: Backend feature-flag service with frontend context and admin status/reload controls.
|
||||||
|
- **Testing**: Fast frontend Vitest unit tests and backend pytest unit tests wired into `restart.sh`.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: FastAPI (Python 3.11)
|
- **Backend**: FastAPI (Python 3.11)
|
||||||
|
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS
|
||||||
- **Database**: MySQL 8.0
|
- **Database**: MySQL 8.0
|
||||||
- **Authentication**: JWT tokens with OAuth2
|
- **Authentication**: JWT tokens with OAuth2
|
||||||
- **Containerization**: Docker & Docker Compose
|
- **Containerization**: Docker & Docker Compose
|
||||||
- **ORM**: SQLAlchemy
|
- **ORM**: SQLAlchemy
|
||||||
|
- **Migrations**: Alembic
|
||||||
|
- **Payments**: Square Web Payments SDK and Square API
|
||||||
|
- **Email**: SMTP2GO
|
||||||
|
- **Tests**: Vitest and pytest
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -37,11 +47,17 @@ membership/
|
|||||||
│ │ │ │ ├── users.py # User management
|
│ │ │ │ ├── users.py # User management
|
||||||
│ │ │ │ ├── tiers.py # Membership tiers
|
│ │ │ │ ├── tiers.py # Membership tiers
|
||||||
│ │ │ │ ├── memberships.py # Membership management
|
│ │ │ │ ├── memberships.py # Membership management
|
||||||
│ │ │ │ └── payments.py # Payment processing
|
│ │ │ │ ├── payments.py # Payment processing
|
||||||
|
│ │ │ │ ├── email.py # SMTP2GO email and bounces
|
||||||
|
│ │ │ │ ├── email_templates.py
|
||||||
|
│ │ │ │ ├── events.py # Events and RSVPs
|
||||||
|
│ │ │ │ ├── esp.py # ESP RFID provisioning, taps, attendance, write jobs
|
||||||
|
│ │ │ │ └── feature_flags.py
|
||||||
│ │ │ └── dependencies.py # Auth dependencies
|
│ │ │ └── dependencies.py # Auth dependencies
|
||||||
│ │ ├── core/
|
│ │ ├── core/
|
||||||
│ │ │ ├── config.py # Configuration
|
│ │ │ ├── config.py # Configuration
|
||||||
│ │ │ ├── database.py # Database setup
|
│ │ │ ├── database.py # Database setup
|
||||||
|
│ │ │ ├── datetime.py # UTC helpers and Zulu serialization helpers
|
||||||
│ │ │ └── security.py # Security utilities
|
│ │ │ └── security.py # Security utilities
|
||||||
│ │ ├── models/
|
│ │ ├── models/
|
||||||
│ │ │ └── models.py # Database models
|
│ │ │ └── models.py # Database models
|
||||||
@@ -50,8 +66,13 @@ membership/
|
|||||||
│ │ └── main.py # Application entry point
|
│ │ └── main.py # Application entry point
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── database/
|
├── frontend/
|
||||||
│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # Dashboard, payment, admin, profile, ESP components
|
||||||
|
│ │ ├── contexts/ # Feature flag, toast, and confirm contexts
|
||||||
|
│ │ ├── pages/ # Login, register, dashboard, policy pages
|
||||||
|
│ │ ├── services/ # API clients
|
||||||
|
│ │ └── utils/ # Tested frontend logic and timezone helpers
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -95,6 +116,25 @@ membership/
|
|||||||
- API Documentation: http://localhost:8050/docs
|
- API Documentation: http://localhost:8050/docs
|
||||||
- TLS certs are generated automatically by the gateway container on first start
|
- TLS certs are generated automatically by the gateway container on first start
|
||||||
|
|
||||||
|
## Restart and Test Gate
|
||||||
|
|
||||||
|
`restart.sh` rebuilds images with cache, runs the fast frontend and backend unit tests, then restarts the stack only if tests pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./restart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The current fast test suite covers:
|
||||||
|
- frontend profile-question visibility and editability rules with Vitest
|
||||||
|
- backend profile-question option parsing, answer normalization/deserialization, select validation, and volunteer flag normalization with pytest
|
||||||
|
|
||||||
|
You can also run them individually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm frontend npm test
|
||||||
|
docker compose run --rm backend pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
## Frontend Development vs Production
|
## Frontend Development vs Production
|
||||||
|
|
||||||
### Development Mode (Vite)
|
### Development Mode (Vite)
|
||||||
@@ -191,6 +231,71 @@ docker compose --profile prod down
|
|||||||
- `PUT /api/v1/payments/{id}` - Update payment (admin)
|
- `PUT /api/v1/payments/{id}` - Update payment (admin)
|
||||||
- `GET /api/v1/payments/` - List all payments (admin)
|
- `GET /api/v1/payments/` - List all payments (admin)
|
||||||
- `POST /api/v1/payments/manual-payment` - Record manual payment (admin)
|
- `POST /api/v1/payments/manual-payment` - Record manual payment (admin)
|
||||||
|
- `GET /api/v1/payments/config/square` - Get frontend Square config
|
||||||
|
- `POST /api/v1/payments/square/process` - Process Square card payment
|
||||||
|
- `POST /api/v1/payments/square/refund` - Refund Square payment (admin)
|
||||||
|
|
||||||
|
### Profile Questions
|
||||||
|
- `GET /api/v1/users/me/profile-questions` - List active questions with current answers
|
||||||
|
- `PUT /api/v1/users/me/profile-answers` - Update editable answers
|
||||||
|
- `GET /api/v1/users/admin/profile-questions` - List all profile questions (admin)
|
||||||
|
- `POST /api/v1/users/admin/profile-questions` - Create profile question (admin)
|
||||||
|
- `PUT /api/v1/users/admin/profile-questions/{id}` - Update profile question (admin)
|
||||||
|
- `DELETE /api/v1/users/admin/profile-questions/{id}` - Deactivate profile question (admin)
|
||||||
|
- `GET /api/v1/users/admin/users/{id}/profile-answers` - View user answers (admin)
|
||||||
|
- `PUT /api/v1/users/admin/users/{id}/profile-answers` - Update user answers (admin)
|
||||||
|
|
||||||
|
### Events
|
||||||
|
- `GET /api/v1/events/` - List events
|
||||||
|
- `GET /api/v1/events/upcoming` - List upcoming events
|
||||||
|
- `POST /api/v1/events/` - Create event (admin)
|
||||||
|
- `PUT /api/v1/events/{id}` - Update event (admin)
|
||||||
|
- `DELETE /api/v1/events/{id}` - Delete event (admin)
|
||||||
|
- `GET /api/v1/events/{id}/rsvps` - List RSVPs (admin)
|
||||||
|
- `POST /api/v1/events/{id}/rsvp` - Create or update current user's RSVP
|
||||||
|
|
||||||
|
### Email and Feature Flags
|
||||||
|
- `POST /api/v1/email/test-email` - Send test email
|
||||||
|
- `POST /api/v1/email/test-welcome-email` - Send test welcome email
|
||||||
|
- `POST /api/v1/email/webhooks/smtp2go/bounce` - Receive SMTP2GO bounce webhook
|
||||||
|
- `GET /api/v1/email/bounces` - List bounces
|
||||||
|
- `GET /api/v1/email/bounces/stats` - Bounce statistics
|
||||||
|
- `GET /api/v1/email-templates/` - List templates
|
||||||
|
- `PUT /api/v1/email-templates/{template_key}` - Update template
|
||||||
|
- `GET /api/v1/feature-flags/flags` - List flags
|
||||||
|
- `POST /api/v1/feature-flags/flags/reload` - Reload flags (super admin)
|
||||||
|
|
||||||
|
### ESP RFID
|
||||||
|
- `POST /api/v1/esp/device/register` - Reader registration and one-time token issuance
|
||||||
|
- `GET /api/v1/esp/device/provisioning-status` - Poll reader provisioning
|
||||||
|
- `GET /api/v1/esp/device/time` - UTC clock sync for ESP firmware
|
||||||
|
- `POST /api/v1/esp/device/heartbeat` - Reader heartbeat with UTC server time
|
||||||
|
- `POST /api/v1/esp/device/taps` - RFID tap capture with UTC-normalized timestamps
|
||||||
|
- `GET /api/v1/esp/device/write-jobs/next` - Poll queued card write job
|
||||||
|
- `POST /api/v1/esp/device/write-jobs/{job_id}/complete` - Complete a queued write job
|
||||||
|
- `POST /api/v1/esp/device/dashboard-login` - Validate ESP-hosted dashboard login
|
||||||
|
- `GET /api/v1/esp/admin/readers` - Admin reader list
|
||||||
|
- `POST /api/v1/esp/admin/readers` - Admin reader create/provision fallback
|
||||||
|
- `PUT /api/v1/esp/admin/readers/{reader_id}` - Admin reader update / key rotation
|
||||||
|
- `POST /api/v1/esp/admin/readers/{reader_id}/approve` - Approve a reader
|
||||||
|
- `POST /api/v1/esp/admin/readers/{reader_id}/reject` - Reject a reader
|
||||||
|
- `DELETE /api/v1/esp/admin/readers/{reader_id}` - Delete a reader
|
||||||
|
- `GET /api/v1/esp/admin/cards` - Admin RFID card list
|
||||||
|
- `POST /api/v1/esp/admin/cards` - Create RFID card
|
||||||
|
- `PUT /api/v1/esp/admin/cards/{card_id}` - Update RFID card
|
||||||
|
- `GET /api/v1/esp/admin/write-jobs` - Admin queued write jobs
|
||||||
|
- `POST /api/v1/esp/admin/write-jobs` - Queue a write job
|
||||||
|
- `POST /api/v1/esp/admin/write-jobs/{job_id}/cancel` - Cancel a queued write job
|
||||||
|
- `GET /api/v1/esp/admin/taps` - Admin tap history
|
||||||
|
- `GET /api/v1/esp/admin/attendance` - Admin attendance sessions
|
||||||
|
- `POST /api/v1/esp/admin/attendance/close-stale` - Close stale attendance sessions
|
||||||
|
|
||||||
|
## Time Handling
|
||||||
|
|
||||||
|
- Database timestamps are stored as UTC and serialized as Zulu (`...Z`) in API responses.
|
||||||
|
- Frontend display uses Europe/London for member-facing dates and times.
|
||||||
|
- Event creation/editing converts London-local input back to UTC before sending it to the backend.
|
||||||
|
- ESP devices sync their clocks from `/api/v1/esp/device/time` and persist tap times as UTC.
|
||||||
|
|
||||||
## Docker Compose Commands
|
## Docker Compose Commands
|
||||||
|
|
||||||
@@ -362,16 +467,13 @@ docker compose up -d
|
|||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Remaining Roadmap
|
||||||
|
|
||||||
- [ ] Implement Square payment integration
|
- [ ] Add member file upload/repository endpoints and UI
|
||||||
- [ ] Add email notification system
|
- [ ] Add richer volunteer role, assignment, schedule, and certificate screens on top of the existing models
|
||||||
- [ ] Create event management endpoints
|
- [ ] Implement automated renewal reminder batch jobs
|
||||||
- [ ] Add volunteer management features
|
|
||||||
- [ ] Build frontend interface
|
|
||||||
- [ ] Add file upload/management
|
|
||||||
- [ ] Implement automated renewal reminders
|
|
||||||
- [ ] Add reporting and analytics
|
- [ ] Add reporting and analytics
|
||||||
|
- [ ] Expand test coverage around authenticated API flows and payment/email service boundaries
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+15
-13
@@ -36,7 +36,7 @@
|
|||||||
- [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide
|
- [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide
|
||||||
- [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details
|
- [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details
|
||||||
- [x] Created `SQUARE_QUICKSTART.md` - Quick start guide
|
- [x] Created `SQUARE_QUICKSTART.md` - Quick start guide
|
||||||
- [x] Created `deploy-square.sh` - Deployment helper script
|
- [x] Updated `restart.sh` - Build, fast tests, and restart helper
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
- [x] No Python syntax errors
|
- [x] No Python syntax errors
|
||||||
@@ -64,13 +64,15 @@ Before deploying, complete these steps:
|
|||||||
- [ ] Set SQUARE_ENVIRONMENT=sandbox
|
- [ ] Set SQUARE_ENVIRONMENT=sandbox
|
||||||
|
|
||||||
### 3. Deployment
|
### 3. Deployment
|
||||||
- [ ] Run `./deploy-square.sh` OR
|
- [ ] Run `./restart.sh` OR
|
||||||
- [ ] Run `docker-compose down`
|
- [ ] Run `docker compose build`
|
||||||
- [ ] Run `docker-compose up -d --build`
|
- [ ] Run `docker compose run --rm frontend npm test`
|
||||||
- [ ] Verify containers are running: `docker-compose ps`
|
- [ ] Run `docker compose run --rm backend pytest -q`
|
||||||
|
- [ ] Run `docker compose up -d`
|
||||||
|
- [ ] Verify containers are running: `docker compose ps`
|
||||||
|
|
||||||
### 4. Testing
|
### 4. Testing
|
||||||
- [ ] Access frontend at http://localhost:3000
|
- [ ] Access frontend at http://localhost:8050 or HTTPS at https://localhost:8443
|
||||||
- [ ] Login/register a user
|
- [ ] Login/register a user
|
||||||
- [ ] Navigate to membership setup
|
- [ ] Navigate to membership setup
|
||||||
- [ ] Select a membership tier
|
- [ ] Select a membership tier
|
||||||
@@ -104,7 +106,7 @@ After deployment, run these commands to verify:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check backend is running
|
# Check backend is running
|
||||||
curl http://localhost:8000/api/v1/payments/config/square
|
curl http://localhost:8050/api/v1/payments/config/square
|
||||||
|
|
||||||
# Expected output (with your actual IDs):
|
# Expected output (with your actual IDs):
|
||||||
# {
|
# {
|
||||||
@@ -114,10 +116,10 @@ curl http://localhost:8000/api/v1/payments/config/square
|
|||||||
# }
|
# }
|
||||||
|
|
||||||
# Check frontend is running
|
# Check frontend is running
|
||||||
curl http://localhost:3000
|
curl http://localhost:8050
|
||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
docker-compose logs backend | grep -i square
|
docker compose logs backend | grep -i square
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 Testing Matrix
|
## 📊 Testing Matrix
|
||||||
@@ -135,13 +137,13 @@ docker-compose logs backend | grep -i square
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check Square SDK installed
|
# Check Square SDK installed
|
||||||
docker-compose exec backend pip list | grep square
|
docker compose exec backend pip list | grep square
|
||||||
|
|
||||||
# Check configuration loaded
|
# Check configuration loaded
|
||||||
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
|
docker compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
|
||||||
|
|
||||||
# Check database has payments
|
# Check database has payments
|
||||||
docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
|
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
|
||||||
|
|
||||||
# Check frontend files
|
# Check frontend files
|
||||||
ls -la frontend/src/components/SquarePayment.tsx
|
ls -la frontend/src/components/SquarePayment.tsx
|
||||||
@@ -151,7 +153,7 @@ ls -la frontend/src/components/SquarePayment.tsx
|
|||||||
|
|
||||||
| Issue | Solution |
|
| Issue | Solution |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| "Module not found: squareup" | Rebuild backend: `docker-compose build backend` |
|
| "Module not found: squareup" | Rebuild backend: `docker compose build backend` |
|
||||||
| "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers |
|
| "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers |
|
||||||
| Square SDK not loading | Check browser console, verify script tag in index.html |
|
| Square SDK not loading | Check browser console, verify script tag in index.html |
|
||||||
| Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct |
|
| Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct |
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ The Square payment integration is complete, tested, and working in sandbox mode:
|
|||||||
- Users can retry failed payments
|
- Users can retry failed payments
|
||||||
- Cash payments still work with PENDING status for admin approval
|
- Cash payments still work with PENDING status for admin approval
|
||||||
- All payment flows properly tested with Square sandbox test cards
|
- All payment flows properly tested with Square sandbox test cards
|
||||||
|
- `restart.sh` now runs the fast Vitest and pytest suites before restarting the stack
|
||||||
|
|
||||||
|
Fast verification commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./restart.sh
|
||||||
|
docker compose run --rm frontend npm test
|
||||||
|
docker compose run --rm backend pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -66,13 +66,14 @@ SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID
|
|||||||
|
|
||||||
### 5. Restart the Application
|
### 5. Restart the Application
|
||||||
|
|
||||||
After updating the environment variables, restart your Docker containers:
|
After updating the environment variables, run the tested restart helper:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose down
|
./restart.sh
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a manual restart, run `docker compose build`, `docker compose run --rm frontend npm test`, `docker compose run --rm backend pytest -q`, and then `docker compose up -d`.
|
||||||
|
|
||||||
## Testing with Sandbox
|
## Testing with Sandbox
|
||||||
|
|
||||||
Square provides test card numbers for sandbox testing:
|
Square provides test card numbers for sandbox testing:
|
||||||
|
|||||||
@@ -38,19 +38,21 @@ SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id...
|
|||||||
Run the deployment script:
|
Run the deployment script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy-square.sh
|
./restart.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
Or manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose down
|
docker compose build
|
||||||
docker-compose up -d --build
|
docker compose run --rm frontend npm test
|
||||||
|
docker compose run --rm backend pytest -q
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Test It Out!
|
### Step 4: Test It Out!
|
||||||
|
|
||||||
1. Open http://localhost:3000
|
1. Open http://localhost:8050 or https://localhost:8443 for HTTPS Square testing
|
||||||
2. Register/login
|
2. Register/login
|
||||||
3. Go to "Setup Membership"
|
3. Go to "Setup Membership"
|
||||||
4. Select a tier
|
4. Select a tier
|
||||||
@@ -78,7 +80,7 @@ docker-compose up -d --build
|
|||||||
- ✅ `.env.example` - UPDATED
|
- ✅ `.env.example` - UPDATED
|
||||||
- ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
|
- ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
|
||||||
- ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
|
- ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
|
||||||
- ✅ `deploy-square.sh` - NEW (deployment helper)
|
- ✅ `restart.sh` - build, fast tests, and restart helper
|
||||||
|
|
||||||
## 🔧 Key Features
|
## 🔧 Key Features
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ User → Select Tier → Choose Payment Method
|
|||||||
|
|
||||||
### Backend won't start?
|
### Backend won't start?
|
||||||
```bash
|
```bash
|
||||||
docker-compose logs backend
|
docker compose logs backend
|
||||||
```
|
```
|
||||||
Check for missing dependencies or configuration errors.
|
Check for missing dependencies or configuration errors.
|
||||||
|
|
||||||
@@ -156,7 +158,7 @@ When ready for production payments:
|
|||||||
|
|
||||||
1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions
|
1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions
|
||||||
2. Review Square's documentation
|
2. Review Square's documentation
|
||||||
3. Check application logs: `docker-compose logs -f backend`
|
3. Check application logs: `docker compose logs -f backend`
|
||||||
4. Contact Square support for payment-specific issues
|
4. Contact Square support for payment-specific issues
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Add volunteer level and dynamic profile questions
|
||||||
|
|
||||||
|
Revision ID: 2e8a0f9d4b31
|
||||||
|
Revises: b583fd2cf202
|
||||||
|
Create Date: 2026-05-04 17:50:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '2e8a0f9d4b31'
|
||||||
|
down_revision: Union[str, None] = 'b583fd2cf202'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('volunteer_level', sa.String(length=50), nullable=True))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'profile_questions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('label', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('help_text', sa.Text(), nullable=True),
|
||||||
|
sa.Column('input_type', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('placeholder', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('options_json', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_required', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('admin_only_edit', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('display_order', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('depends_on_question_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('depends_on_value', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['depends_on_question_id'], ['profile_questions.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_profile_questions_id'), 'profile_questions', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_profile_questions_key'), 'profile_questions', ['key'], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'user_profile_answers',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('question_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('value_text', sa.Text(), nullable=True),
|
||||||
|
sa.Column('updated_by_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['question_id'], ['profile_questions.id']),
|
||||||
|
sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id']),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'question_id', name='uq_user_profile_answer'),
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_user_profile_answers_id'), 'user_profile_answers', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_user_profile_answers_question_id'), 'user_profile_answers', ['question_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_user_profile_answers_user_id'), 'user_profile_answers', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_user_profile_answers_user_id'), table_name='user_profile_answers')
|
||||||
|
op.drop_index(op.f('ix_user_profile_answers_question_id'), table_name='user_profile_answers')
|
||||||
|
op.drop_index(op.f('ix_user_profile_answers_id'), table_name='user_profile_answers')
|
||||||
|
op.drop_table('user_profile_answers')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_profile_questions_key'), table_name='profile_questions')
|
||||||
|
op.drop_index(op.f('ix_profile_questions_id'), table_name='profile_questions')
|
||||||
|
op.drop_table('profile_questions')
|
||||||
|
|
||||||
|
op.drop_column('users', 'volunteer_level')
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
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()
|
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(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
||||||
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
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(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
|
import uuid
|
||||||
|
|
||||||
from ...core.database import get_db
|
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 ...core.security import verify_password, get_password_hash, create_access_token
|
||||||
from ...models.models import User, UserRole, PasswordResetToken
|
from ...models.models import User, UserRole, PasswordResetToken
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
@@ -85,7 +86,7 @@ async def login(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
user.last_login = datetime.utcnow()
|
user.last_login = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Create access token
|
# Create access token
|
||||||
@@ -120,7 +121,7 @@ async def login_json(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
user.last_login = datetime.utcnow()
|
user.last_login = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Create access token
|
# Create access token
|
||||||
@@ -149,12 +150,12 @@ async def forgot_password(
|
|||||||
db.query(PasswordResetToken).filter(
|
db.query(PasswordResetToken).filter(
|
||||||
PasswordResetToken.user_id == user.id,
|
PasswordResetToken.user_id == user.id,
|
||||||
PasswordResetToken.used == False,
|
PasswordResetToken.used == False,
|
||||||
PasswordResetToken.expires_at > datetime.utcnow()
|
PasswordResetToken.expires_at > utc_now()
|
||||||
).update({"used": True})
|
).update({"used": True})
|
||||||
|
|
||||||
# Generate new reset token
|
# Generate new reset token
|
||||||
reset_token = str(uuid.uuid4())
|
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
|
# Create password reset token
|
||||||
db_token = PasswordResetToken(
|
db_token = PasswordResetToken(
|
||||||
@@ -192,7 +193,7 @@ async def reset_password(
|
|||||||
reset_token = db.query(PasswordResetToken).filter(
|
reset_token = db.query(PasswordResetToken).filter(
|
||||||
PasswordResetToken.token == request.token,
|
PasswordResetToken.token == request.token,
|
||||||
PasswordResetToken.used == False,
|
PasswordResetToken.used == False,
|
||||||
PasswordResetToken.expires_at > datetime.utcnow()
|
PasswordResetToken.expires_at > utc_now()
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not reset_token:
|
if not reset_token:
|
||||||
@@ -212,7 +213,7 @@ async def reset_password(
|
|||||||
# Update password
|
# Update password
|
||||||
hashed_password = get_password_hash(request.new_password)
|
hashed_password = get_password_hash(request.new_password)
|
||||||
user.hashed_password = hashed_password
|
user.hashed_password = hashed_password
|
||||||
user.updated_at = datetime.utcnow()
|
user.updated_at = utc_now()
|
||||||
|
|
||||||
# Mark token as used
|
# Mark token as used
|
||||||
reset_token.used = True
|
reset_token.used = True
|
||||||
@@ -239,7 +240,7 @@ async def change_password(
|
|||||||
# Update password
|
# Update password
|
||||||
hashed_password = get_password_hash(request.new_password)
|
hashed_password = get_password_hash(request.new_password)
|
||||||
current_user.hashed_password = hashed_password
|
current_user.hashed_password = hashed_password
|
||||||
current_user.updated_at = datetime.utcnow()
|
current_user.updated_at = utc_now()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from ...api.dependencies import get_admin_user
|
|||||||
from ...models.models import User
|
from ...models.models import User
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
|
from ...core.datetime import to_zulu_iso
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -95,7 +96,7 @@ async def get_bounce_list(
|
|||||||
"email": bounce.email,
|
"email": bounce.email,
|
||||||
"bounce_type": bounce.bounce_type.value,
|
"bounce_type": bounce.bounce_type.value,
|
||||||
"bounce_reason": bounce.bounce_reason,
|
"bounce_reason": bounce.bounce_reason,
|
||||||
"bounce_date": bounce.bounce_date.isoformat(),
|
"bounce_date": to_zulu_iso(bounce.bounce_date),
|
||||||
"is_active": bounce.is_active,
|
"is_active": bounce.is_active,
|
||||||
"smtp2go_message_id": bounce.smtp2go_message_id
|
"smtp2go_message_id": bounce.smtp2go_message_id
|
||||||
}
|
}
|
||||||
@@ -132,7 +133,7 @@ async def get_bounce_history(
|
|||||||
"id": bounce.id,
|
"id": bounce.id,
|
||||||
"bounce_type": bounce.bounce_type.value,
|
"bounce_type": bounce.bounce_type.value,
|
||||||
"bounce_reason": bounce.bounce_reason,
|
"bounce_reason": bounce.bounce_reason,
|
||||||
"bounce_date": bounce.bounce_date.isoformat(),
|
"bounce_date": to_zulu_iso(bounce.bounce_date),
|
||||||
"is_active": bounce.is_active,
|
"is_active": bounce.is_active,
|
||||||
"smtp2go_message_id": bounce.smtp2go_message_id
|
"smtp2go_message_id": bounce.smtp2go_message_id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
|
from ...core.datetime import utc_now
|
||||||
from ...models.models import Event, EventRSVP, User, EventStatus
|
from ...models.models import Event, EventRSVP, User, EventStatus
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
|
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
|
||||||
@@ -13,6 +13,10 @@ from ...api.dependencies import get_current_active_user, get_admin_user
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_time_string(value) -> str:
|
||||||
|
return value.strftime("%H:%M")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[EventResponse])
|
@router.get("/", response_model=List[EventResponse])
|
||||||
async def get_events(
|
async def get_events(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
@@ -34,9 +38,9 @@ async def get_upcoming_events(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get upcoming events"""
|
"""Get upcoming events"""
|
||||||
now = datetime.now()
|
now = utc_now()
|
||||||
events = db.query(Event).filter(
|
events = db.query(Event).filter(
|
||||||
Event.event_date >= now.date(),
|
Event.event_date >= now,
|
||||||
Event.status == EventStatus.PUBLISHED
|
Event.status == EventStatus.PUBLISHED
|
||||||
).order_by(Event.event_date).all()
|
).order_by(Event.event_date).all()
|
||||||
return events
|
return events
|
||||||
@@ -50,7 +54,7 @@ async def create_event(
|
|||||||
):
|
):
|
||||||
"""Create a new event (admin only)"""
|
"""Create a new event (admin only)"""
|
||||||
# Validate event date is in the future
|
# Validate event date is in the future
|
||||||
if event_data.event_date < datetime.now():
|
if event_data.event_date < utc_now():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Event date must be in the future"
|
detail="Event date must be in the future"
|
||||||
@@ -60,7 +64,7 @@ async def create_event(
|
|||||||
title=event_data.title,
|
title=event_data.title,
|
||||||
description=event_data.description,
|
description=event_data.description,
|
||||||
event_date=event_data.event_date,
|
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,
|
location=event_data.location,
|
||||||
max_attendees=event_data.max_attendees,
|
max_attendees=event_data.max_attendees,
|
||||||
status=EventStatus.DRAFT,
|
status=EventStatus.DRAFT,
|
||||||
@@ -89,10 +93,14 @@ async def update_event(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update fields
|
# 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)
|
setattr(event, field, value)
|
||||||
|
|
||||||
event.updated_at = datetime.now()
|
event.updated_at = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(event)
|
db.refresh(event)
|
||||||
return event
|
return event
|
||||||
@@ -167,7 +175,7 @@ async def create_or_update_rsvp(
|
|||||||
existing_rsvp.status = rsvp_data.status
|
existing_rsvp.status = rsvp_data.status
|
||||||
if rsvp_data.notes is not None:
|
if rsvp_data.notes is not None:
|
||||||
existing_rsvp.notes = rsvp_data.notes
|
existing_rsvp.notes = rsvp_data.notes
|
||||||
existing_rsvp.updated_at = datetime.now()
|
existing_rsvp.updated_at = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(existing_rsvp)
|
db.refresh(existing_rsvp)
|
||||||
return existing_rsvp
|
return existing_rsvp
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from typing import Dict, Any
|
|
||||||
from app.services.feature_flag_service import feature_flags
|
from app.services.feature_flag_service import feature_flags
|
||||||
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
|
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
|
||||||
|
from app.api.dependencies import get_super_admin_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -38,10 +38,11 @@ async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/flags/reload")
|
@router.post("/flags/reload")
|
||||||
async def reload_feature_flags():
|
async def reload_feature_flags(
|
||||||
|
current_user = Depends(get_super_admin_user),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Reload feature flags from environment variables
|
Reload feature flags from environment variables.
|
||||||
This could be protected with admin permissions in production
|
|
||||||
"""
|
"""
|
||||||
feature_flags.reload_flags()
|
feature_flags.reload_flags()
|
||||||
return {"message": "Feature flags reloaded successfully"}
|
return {"message": "Feature flags reloaded successfully"}
|
||||||
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from ...core.database import get_db
|
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 ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse,
|
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse,
|
||||||
@@ -121,7 +122,7 @@ async def update_payment(
|
|||||||
|
|
||||||
# If marking as completed, set payment_date if not already set
|
# If marking as completed, set payment_date if not already set
|
||||||
if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date:
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(payment, field, value)
|
setattr(payment, field, value)
|
||||||
@@ -182,7 +183,7 @@ async def process_square_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create a reference ID for tracking
|
# 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
|
# Process payment with Square
|
||||||
square_result = await square_service.create_payment(
|
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
|
# Payment succeeded - create membership and payment records in a transaction
|
||||||
try:
|
try:
|
||||||
# Calculate membership dates
|
# Calculate membership dates
|
||||||
start_date = datetime.utcnow().date()
|
start_date = utc_now().date()
|
||||||
end_date = start_date + relativedelta(years=1)
|
end_date = start_date + relativedelta(years=1)
|
||||||
|
|
||||||
# Create membership with ACTIVE status
|
# Create membership with ACTIVE status
|
||||||
@@ -226,7 +227,7 @@ async def process_square_payment(
|
|||||||
payment_method=PaymentMethod.SQUARE,
|
payment_method=PaymentMethod.SQUARE,
|
||||||
status=PaymentStatus.COMPLETED,
|
status=PaymentStatus.COMPLETED,
|
||||||
transaction_id=square_result.get('payment_id'),
|
transaction_id=square_result.get('payment_id'),
|
||||||
payment_date=datetime.utcnow(),
|
payment_date=utc_now(),
|
||||||
notes=payment_request.note
|
notes=payment_request.note
|
||||||
)
|
)
|
||||||
db.add(payment)
|
db.add(payment)
|
||||||
@@ -389,7 +390,7 @@ async def record_manual_payment(
|
|||||||
payment_method=payment_data.payment_method,
|
payment_method=payment_data.payment_method,
|
||||||
notes=payment_data.notes,
|
notes=payment_data.notes,
|
||||||
status=PaymentStatus.COMPLETED,
|
status=PaymentStatus.COMPLETED,
|
||||||
payment_date=datetime.utcnow()
|
payment_date=utc_now()
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(payment)
|
db.add(payment)
|
||||||
|
|||||||
+632
-7
@@ -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 fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...core.security import get_password_hash
|
from ...core.datetime import utc_now
|
||||||
from ...models.models import User
|
from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
|
||||||
from ...schemas import UserResponse, UserUpdate, MessageResponse
|
from ...schemas import (
|
||||||
|
MessageResponse,
|
||||||
|
ProfileAnswersUpdateRequest,
|
||||||
|
ProfileQuestionCreate,
|
||||||
|
ProfileQuestionForUser,
|
||||||
|
ProfileQuestionResponse,
|
||||||
|
ProfileQuestionUpdate,
|
||||||
|
UserResponse,
|
||||||
|
UserUpdate,
|
||||||
|
)
|
||||||
from ...api.dependencies import get_current_active_user, get_admin_user
|
from ...api.dependencies import get_current_active_user, get_admin_user
|
||||||
|
from ...services.email_service import email_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_options(options_json: Optional[str]) -> list[dict[str, str]]:
|
||||||
|
if not options_json:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(options_json)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: list[dict[str, str]] = []
|
||||||
|
for item in parsed:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
label = str(item.get("label", "")).strip()
|
||||||
|
value = str(item.get("value", "")).strip()
|
||||||
|
if label and value:
|
||||||
|
normalized.append({"label": label, "value": value})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_options(options: Optional[list[Any]]) -> Optional[str]:
|
||||||
|
if not options:
|
||||||
|
return None
|
||||||
|
normalized = []
|
||||||
|
for item in options:
|
||||||
|
data = item.model_dump() if hasattr(item, "model_dump") else item
|
||||||
|
normalized.append({"label": str(data["label"]), "value": str(data["value"])})
|
||||||
|
return json.dumps(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_answer_value(question: ProfileQuestion, value: Any) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(value, str) and value.strip() == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
input_type = question.input_type
|
||||||
|
|
||||||
|
if input_type == "boolean":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text in {"true", "1", "yes", "y"}:
|
||||||
|
return "true"
|
||||||
|
if text in {"false", "0", "no", "n"}:
|
||||||
|
return "false"
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid boolean answer for question '{question.key}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if input_type == "number":
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
return str(int(number)) if number.is_integer() else str(number)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid number answer for question '{question.key}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if input_type == "date":
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date().isoformat()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(text, "%Y-%m-%d")
|
||||||
|
return parsed.date().isoformat()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid date answer for question '{question.key}'. Use YYYY-MM-DD"
|
||||||
|
)
|
||||||
|
|
||||||
|
if input_type == "select":
|
||||||
|
selected = str(value).strip()
|
||||||
|
option_values = {opt["value"] for opt in _parse_options(question.options_json)}
|
||||||
|
if selected not in option_values:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid selection for question '{question.key}'"
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_answer_value(question: ProfileQuestion, value_text: Optional[str]) -> Any:
|
||||||
|
if value_text is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if question.input_type == "boolean":
|
||||||
|
return value_text.lower() == "true"
|
||||||
|
|
||||||
|
if question.input_type == "number":
|
||||||
|
try:
|
||||||
|
number = float(value_text)
|
||||||
|
return int(number) if number.is_integer() else number
|
||||||
|
except ValueError:
|
||||||
|
return value_text
|
||||||
|
|
||||||
|
return value_text
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_question_dependencies(
|
||||||
|
db: Session,
|
||||||
|
depends_on_question_id: Optional[int],
|
||||||
|
depends_on_value: Optional[str],
|
||||||
|
current_question_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
if depends_on_question_id is None:
|
||||||
|
if depends_on_value is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="depends_on_value requires depends_on_question_id"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
dependent_question = db.query(ProfileQuestion).filter(ProfileQuestion.id == depends_on_question_id).first()
|
||||||
|
if not dependent_question:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="depends_on_question_id does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_question_id is not None and current_question_id == depends_on_question_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="A question cannot depend on itself"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_volunteer_level(value: Optional[str]) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if normalized in {"yes", "true", "1"}:
|
||||||
|
return "yes"
|
||||||
|
if normalized in {"no", "false", "0"}:
|
||||||
|
return "no"
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Volunteer flag must be yes or no"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_current_user_profile(
|
async def get_current_user_profile(
|
||||||
current_user: User = Depends(get_current_active_user)
|
current_user: User = Depends(get_current_active_user)
|
||||||
@@ -28,9 +201,12 @@ async def update_current_user_profile(
|
|||||||
"""Update current user's profile"""
|
"""Update current user's profile"""
|
||||||
update_data = user_update.model_dump(exclude_unset=True)
|
update_data = user_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Check email uniqueness if email is being updated
|
# Prevent privilege and volunteer-level edits through self-service profile endpoint.
|
||||||
if 'email' in update_data and update_data['email'] != current_user.email:
|
update_data.pop("role", None)
|
||||||
existing_user = db.query(User).filter(User.email == update_data['email']).first()
|
update_data.pop("volunteer_level", None)
|
||||||
|
|
||||||
|
if "email" in update_data and update_data["email"] != current_user.email:
|
||||||
|
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -46,6 +222,101 @@ async def update_current_user_profile(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/profile-questions", response_model=List[ProfileQuestionForUser])
|
||||||
|
async def list_my_profile_questions(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
questions = db.query(ProfileQuestion).filter(ProfileQuestion.is_active == True).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
|
||||||
|
|
||||||
|
answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == current_user.id).all()
|
||||||
|
answers_by_question = {answer.question_id: answer for answer in answers}
|
||||||
|
|
||||||
|
response: list[ProfileQuestionForUser] = []
|
||||||
|
for question in questions:
|
||||||
|
user_answer = answers_by_question.get(question.id)
|
||||||
|
can_edit = (not question.admin_only_edit) or (current_user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN])
|
||||||
|
|
||||||
|
response.append(
|
||||||
|
ProfileQuestionForUser(
|
||||||
|
id=question.id,
|
||||||
|
key=question.key,
|
||||||
|
label=question.label,
|
||||||
|
help_text=question.help_text,
|
||||||
|
input_type=question.input_type,
|
||||||
|
placeholder=question.placeholder,
|
||||||
|
options=_parse_options(question.options_json),
|
||||||
|
is_required=question.is_required,
|
||||||
|
is_active=question.is_active,
|
||||||
|
admin_only_edit=question.admin_only_edit,
|
||||||
|
display_order=question.display_order,
|
||||||
|
depends_on_question_id=question.depends_on_question_id,
|
||||||
|
depends_on_value=question.depends_on_value,
|
||||||
|
created_at=question.created_at,
|
||||||
|
updated_at=question.updated_at,
|
||||||
|
answer=_deserialize_answer_value(question, user_answer.value_text if user_answer else None),
|
||||||
|
can_edit=can_edit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/profile-answers", response_model=MessageResponse)
|
||||||
|
async def update_my_profile_answers(
|
||||||
|
payload: ProfileAnswersUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if not payload.answers:
|
||||||
|
return {"message": "No changes submitted"}
|
||||||
|
|
||||||
|
question_ids = {item.question_id for item in payload.answers}
|
||||||
|
questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids), ProfileQuestion.is_active == True).all()
|
||||||
|
questions_by_id = {question.id: question for question in questions}
|
||||||
|
|
||||||
|
missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id]
|
||||||
|
if missing_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Questions not found: {missing_ids}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in payload.answers:
|
||||||
|
question = questions_by_id[item.question_id]
|
||||||
|
if question.admin_only_edit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Question '{question.label}' can only be changed by admins"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_value = _normalize_answer_value(question, item.value)
|
||||||
|
|
||||||
|
answer = db.query(UserProfileAnswer).filter(
|
||||||
|
UserProfileAnswer.user_id == current_user.id,
|
||||||
|
UserProfileAnswer.question_id == question.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if normalized_value is None:
|
||||||
|
if answer:
|
||||||
|
db.delete(answer)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if answer:
|
||||||
|
answer.value_text = normalized_value
|
||||||
|
answer.updated_by_user_id = current_user.id
|
||||||
|
else:
|
||||||
|
db.add(UserProfileAnswer(
|
||||||
|
user_id=current_user.id,
|
||||||
|
question_id=question.id,
|
||||||
|
value_text=normalized_value,
|
||||||
|
updated_by_user_id=current_user.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Profile answers updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[UserResponse])
|
@router.get("/", response_model=List[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -58,6 +329,281 @@ async def list_users(
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/profile-questions", response_model=List[ProfileQuestionResponse])
|
||||||
|
async def list_profile_questions_admin(
|
||||||
|
include_inactive: bool = True,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
query = db.query(ProfileQuestion)
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.filter(ProfileQuestion.is_active == True)
|
||||||
|
|
||||||
|
questions = query.order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
ProfileQuestionResponse(
|
||||||
|
id=question.id,
|
||||||
|
key=question.key,
|
||||||
|
label=question.label,
|
||||||
|
help_text=question.help_text,
|
||||||
|
input_type=question.input_type,
|
||||||
|
placeholder=question.placeholder,
|
||||||
|
options=_parse_options(question.options_json),
|
||||||
|
is_required=question.is_required,
|
||||||
|
is_active=question.is_active,
|
||||||
|
admin_only_edit=question.admin_only_edit,
|
||||||
|
display_order=question.display_order,
|
||||||
|
depends_on_question_id=question.depends_on_question_id,
|
||||||
|
depends_on_value=question.depends_on_value,
|
||||||
|
created_at=question.created_at,
|
||||||
|
updated_at=question.updated_at,
|
||||||
|
)
|
||||||
|
for question in questions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/profile-questions", response_model=ProfileQuestionResponse)
|
||||||
|
async def create_profile_question_admin(
|
||||||
|
payload: ProfileQuestionCreate,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if payload.input_type == "select" and not payload.options:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Select questions require options"
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_question_dependencies(db, payload.depends_on_question_id, payload.depends_on_value)
|
||||||
|
|
||||||
|
existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == payload.key).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Question key already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
question = ProfileQuestion(
|
||||||
|
key=payload.key,
|
||||||
|
label=payload.label,
|
||||||
|
help_text=payload.help_text,
|
||||||
|
input_type=payload.input_type,
|
||||||
|
placeholder=payload.placeholder,
|
||||||
|
options_json=_serialize_options(payload.options),
|
||||||
|
is_required=payload.is_required,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
admin_only_edit=payload.admin_only_edit,
|
||||||
|
display_order=payload.display_order,
|
||||||
|
depends_on_question_id=payload.depends_on_question_id,
|
||||||
|
depends_on_value=payload.depends_on_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(question)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(question)
|
||||||
|
|
||||||
|
return ProfileQuestionResponse(
|
||||||
|
id=question.id,
|
||||||
|
key=question.key,
|
||||||
|
label=question.label,
|
||||||
|
help_text=question.help_text,
|
||||||
|
input_type=question.input_type,
|
||||||
|
placeholder=question.placeholder,
|
||||||
|
options=_parse_options(question.options_json),
|
||||||
|
is_required=question.is_required,
|
||||||
|
is_active=question.is_active,
|
||||||
|
admin_only_edit=question.admin_only_edit,
|
||||||
|
display_order=question.display_order,
|
||||||
|
depends_on_question_id=question.depends_on_question_id,
|
||||||
|
depends_on_value=question.depends_on_value,
|
||||||
|
created_at=question.created_at,
|
||||||
|
updated_at=question.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/profile-questions/{question_id}", response_model=ProfileQuestionResponse)
|
||||||
|
async def update_profile_question_admin(
|
||||||
|
question_id: int,
|
||||||
|
payload: ProfileQuestionUpdate,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first()
|
||||||
|
if not question:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Question not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if "key" in update_data and update_data["key"] != question.key:
|
||||||
|
existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == update_data["key"]).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Question key already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
input_type = update_data.get("input_type", question.input_type)
|
||||||
|
options = update_data.get("options")
|
||||||
|
options_to_validate = options if options is not None else _parse_options(question.options_json)
|
||||||
|
if input_type == "select" and not options_to_validate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Select questions require options"
|
||||||
|
)
|
||||||
|
|
||||||
|
depends_on_question_id = update_data.get("depends_on_question_id", question.depends_on_question_id)
|
||||||
|
depends_on_value = update_data.get("depends_on_value", question.depends_on_value)
|
||||||
|
_validate_question_dependencies(db, depends_on_question_id, depends_on_value, current_question_id=question.id)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if field == "options":
|
||||||
|
question.options_json = _serialize_options(value)
|
||||||
|
else:
|
||||||
|
setattr(question, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(question)
|
||||||
|
|
||||||
|
return ProfileQuestionResponse(
|
||||||
|
id=question.id,
|
||||||
|
key=question.key,
|
||||||
|
label=question.label,
|
||||||
|
help_text=question.help_text,
|
||||||
|
input_type=question.input_type,
|
||||||
|
placeholder=question.placeholder,
|
||||||
|
options=_parse_options(question.options_json),
|
||||||
|
is_required=question.is_required,
|
||||||
|
is_active=question.is_active,
|
||||||
|
admin_only_edit=question.admin_only_edit,
|
||||||
|
display_order=question.display_order,
|
||||||
|
depends_on_question_id=question.depends_on_question_id,
|
||||||
|
depends_on_value=question.depends_on_value,
|
||||||
|
created_at=question.created_at,
|
||||||
|
updated_at=question.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/profile-questions/{question_id}", response_model=MessageResponse)
|
||||||
|
async def deactivate_profile_question_admin(
|
||||||
|
question_id: int,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first()
|
||||||
|
if not question:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Question not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
question.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Question deactivated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{user_id}/profile-answers", response_model=List[ProfileQuestionForUser])
|
||||||
|
async def get_user_profile_answers_admin(
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
questions = db.query(ProfileQuestion).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
|
||||||
|
answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == user_id).all()
|
||||||
|
answers_by_question = {answer.question_id: answer for answer in answers}
|
||||||
|
|
||||||
|
return [
|
||||||
|
ProfileQuestionForUser(
|
||||||
|
id=question.id,
|
||||||
|
key=question.key,
|
||||||
|
label=question.label,
|
||||||
|
help_text=question.help_text,
|
||||||
|
input_type=question.input_type,
|
||||||
|
placeholder=question.placeholder,
|
||||||
|
options=_parse_options(question.options_json),
|
||||||
|
is_required=question.is_required,
|
||||||
|
is_active=question.is_active,
|
||||||
|
admin_only_edit=question.admin_only_edit,
|
||||||
|
display_order=question.display_order,
|
||||||
|
depends_on_question_id=question.depends_on_question_id,
|
||||||
|
depends_on_value=question.depends_on_value,
|
||||||
|
created_at=question.created_at,
|
||||||
|
updated_at=question.updated_at,
|
||||||
|
answer=_deserialize_answer_value(question, answers_by_question.get(question.id).value_text if answers_by_question.get(question.id) else None),
|
||||||
|
can_edit=True,
|
||||||
|
)
|
||||||
|
for question in questions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/users/{user_id}/profile-answers", response_model=MessageResponse)
|
||||||
|
async def update_user_profile_answers_admin(
|
||||||
|
user_id: int,
|
||||||
|
payload: ProfileAnswersUpdateRequest,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not payload.answers:
|
||||||
|
return {"message": "No changes submitted"}
|
||||||
|
|
||||||
|
question_ids = {item.question_id for item in payload.answers}
|
||||||
|
questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids)).all()
|
||||||
|
questions_by_id = {question.id: question for question in questions}
|
||||||
|
|
||||||
|
missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id]
|
||||||
|
if missing_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Questions not found: {missing_ids}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in payload.answers:
|
||||||
|
question = questions_by_id[item.question_id]
|
||||||
|
normalized_value = _normalize_answer_value(question, item.value)
|
||||||
|
|
||||||
|
answer = db.query(UserProfileAnswer).filter(
|
||||||
|
UserProfileAnswer.user_id == user_id,
|
||||||
|
UserProfileAnswer.question_id == question.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if normalized_value is None:
|
||||||
|
if answer:
|
||||||
|
db.delete(answer)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if answer:
|
||||||
|
answer.value_text = normalized_value
|
||||||
|
answer.updated_by_user_id = current_user.id
|
||||||
|
else:
|
||||||
|
db.add(UserProfileAnswer(
|
||||||
|
user_id=user_id,
|
||||||
|
question_id=question.id,
|
||||||
|
value_text=normalized_value,
|
||||||
|
updated_by_user_id=current_user.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "User profile answers updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserResponse)
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -91,6 +637,23 @@ async def update_user(
|
|||||||
|
|
||||||
update_data = user_update.model_dump(exclude_unset=True)
|
update_data = user_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if "email" in update_data and update_data["email"] != user.email:
|
||||||
|
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "role" in update_data and update_data["role"] == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only super admins can assign super admin role"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "volunteer_level" in update_data:
|
||||||
|
update_data["volunteer_level"] = _normalize_volunteer_level(update_data["volunteer_level"])
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
|
|
||||||
@@ -100,6 +663,68 @@ async def update_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/send-password-reset", response_model=MessageResponse)
|
||||||
|
async def send_user_password_reset(
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Send a one-time password reset link email for a user."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only super admins can send password reset emails for admin users"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot reset password for inactive user"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.query(PasswordResetToken).filter(
|
||||||
|
PasswordResetToken.user_id == user.id,
|
||||||
|
PasswordResetToken.used == False,
|
||||||
|
PasswordResetToken.expires_at > 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)
|
@router.delete("/{user_id}", response_model=MessageResponse)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pathlib import Path
|
||||||
from typing import List
|
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):
|
class Settings(BaseSettings):
|
||||||
@@ -47,9 +52,11 @@ class Settings(BaseSettings):
|
|||||||
UPLOAD_DIR: str = "/app/uploads"
|
UPLOAD_DIR: str = "/app/uploads"
|
||||||
MAX_UPLOAD_SIZE: int = 10485760 # 10MB
|
MAX_UPLOAD_SIZE: int = 10485760 # 10MB
|
||||||
|
|
||||||
class Config:
|
model_config = SettingsConfigDict(
|
||||||
env_file = ".env"
|
env_file=(PROJECT_ROOT / ".env", BACKEND_ROOT / ".env", ".env"),
|
||||||
case_sensitive = True
|
case_sensitive=True,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..models.models import MembershipTier, User, UserRole, EmailTemplate
|
import json
|
||||||
|
|
||||||
|
from ..models.models import MembershipTier, User, UserRole, EmailTemplate, ProfileQuestion
|
||||||
from .security import get_password_hash
|
from .security import get_password_hash
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -70,3 +72,99 @@ def init_default_data(db: Session):
|
|||||||
db.add_all(default_templates)
|
db.add_all(default_templates)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"✓ Created {len(default_templates)} default email templates")
|
print(f"✓ Created {len(default_templates)} default email templates")
|
||||||
|
|
||||||
|
# Seed default profile questions for onboarding and profile attributes
|
||||||
|
existing_questions = db.query(ProfileQuestion).count()
|
||||||
|
if existing_questions == 0:
|
||||||
|
print("Creating default profile questions...")
|
||||||
|
default_questions = [
|
||||||
|
ProfileQuestion(
|
||||||
|
key="has_professional_license",
|
||||||
|
label="Do you hold a professional aviation-related license?",
|
||||||
|
help_text="Select your current license status.",
|
||||||
|
input_type="select",
|
||||||
|
options_json=json.dumps([
|
||||||
|
{"label": "No", "value": "none"},
|
||||||
|
{"label": "Student", "value": "student"},
|
||||||
|
{"label": "Private Pilot", "value": "ppl"},
|
||||||
|
{"label": "Commercial Pilot", "value": "cpl"},
|
||||||
|
{"label": "ATPL", "value": "atpl"},
|
||||||
|
{"label": "Instructor", "value": "instructor"},
|
||||||
|
]),
|
||||||
|
is_required=False,
|
||||||
|
is_active=True,
|
||||||
|
admin_only_edit=False,
|
||||||
|
display_order=10,
|
||||||
|
),
|
||||||
|
ProfileQuestion(
|
||||||
|
key="license_number",
|
||||||
|
label="License number",
|
||||||
|
help_text="Optional: your current license number.",
|
||||||
|
input_type="text",
|
||||||
|
placeholder="e.g. UK.FCL.123456",
|
||||||
|
is_required=False,
|
||||||
|
is_active=True,
|
||||||
|
admin_only_edit=False,
|
||||||
|
display_order=20,
|
||||||
|
depends_on_value="ppl",
|
||||||
|
),
|
||||||
|
ProfileQuestion(
|
||||||
|
key="can_support_events",
|
||||||
|
label="Can you support airport or membership events?",
|
||||||
|
help_text="Choose yes if you're open to helping with events.",
|
||||||
|
input_type="boolean",
|
||||||
|
is_required=False,
|
||||||
|
is_active=True,
|
||||||
|
admin_only_edit=False,
|
||||||
|
display_order=30,
|
||||||
|
),
|
||||||
|
ProfileQuestion(
|
||||||
|
key="event_support_notes",
|
||||||
|
label="What support can you offer?",
|
||||||
|
help_text="Examples: stewarding, admin desk, setup/packdown, mentoring.",
|
||||||
|
input_type="text",
|
||||||
|
placeholder="Type details here",
|
||||||
|
is_required=False,
|
||||||
|
is_active=True,
|
||||||
|
admin_only_edit=False,
|
||||||
|
display_order=40,
|
||||||
|
depends_on_value="true",
|
||||||
|
),
|
||||||
|
ProfileQuestion(
|
||||||
|
key="hours_available_monthly",
|
||||||
|
label="Approximate volunteer hours available each month",
|
||||||
|
help_text="Optional estimate in hours.",
|
||||||
|
input_type="number",
|
||||||
|
is_required=False,
|
||||||
|
is_active=True,
|
||||||
|
admin_only_edit=False,
|
||||||
|
display_order=50,
|
||||||
|
),
|
||||||
|
ProfileQuestion(
|
||||||
|
key="medical_expiry_date",
|
||||||
|
label="Medical certificate expiry date",
|
||||||
|
help_text="Optional date in YYYY-MM-DD format.",
|
||||||
|
input_type="date",
|
||||||
|
is_required=False,
|
||||||
|
is_active=True,
|
||||||
|
admin_only_edit=False,
|
||||||
|
display_order=60,
|
||||||
|
),
|
||||||
|
ProfileQuestion(
|
||||||
|
key="completed_training_x",
|
||||||
|
label="Completed Training X",
|
||||||
|
help_text="This is set by admins once verified.",
|
||||||
|
input_type="boolean",
|
||||||
|
is_required=False,
|
||||||
|
is_active=True,
|
||||||
|
admin_only_edit=True,
|
||||||
|
display_order=70,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db.add_all(default_questions)
|
||||||
|
db.commit()
|
||||||
|
question_by_key = {question.key: question for question in db.query(ProfileQuestion).all()}
|
||||||
|
question_by_key["license_number"].depends_on_question_id = question_by_key["has_professional_license"].id
|
||||||
|
question_by_key["event_support_notes"].depends_on_question_id = question_by_key["can_support_events"].id
|
||||||
|
db.commit()
|
||||||
|
print(f"✓ Created {len(default_questions)} default profile questions")
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Union, Any
|
from typing import Optional, Union, Any
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
from .datetime import utc_now
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
MACHINE_TOKEN_PREFIX = "sha256$"
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
def create_access_token(
|
||||||
@@ -12,9 +16,9 @@ def create_access_token(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Create JWT access token"""
|
"""Create JWT access token"""
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = utc_now() + expires_delta
|
||||||
else:
|
else:
|
||||||
expire = datetime.utcnow() + timedelta(
|
expire = utc_now() + timedelta(
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +37,26 @@ def get_password_hash(password: str) -> str:
|
|||||||
return pwd_context.hash(password)
|
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]:
|
def decode_token(token: str) -> Optional[str]:
|
||||||
"""Decode JWT token and return subject"""
|
"""Decode JWT token and return subject"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+36
-2
@@ -1,13 +1,30 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi import Request
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
from .api.v1 import api_router
|
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 .core.init_db import init_default_data
|
||||||
|
from .services.attendance_service import close_stale_attendance_sessions
|
||||||
from sqlalchemy.orm import Session
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Handle startup and shutdown events"""
|
"""Handle startup and shutdown events"""
|
||||||
@@ -15,13 +32,20 @@ async def lifespan(app: FastAPI):
|
|||||||
db: Session = next(get_db())
|
db: Session = next(get_db())
|
||||||
try:
|
try:
|
||||||
init_default_data(db)
|
init_default_data(db)
|
||||||
|
close_stale_attendance_sessions(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
attendance_task = asyncio.create_task(close_stale_attendance_loop())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown (if needed)
|
# Shutdown (if needed)
|
||||||
pass
|
attendance_task.cancel()
|
||||||
|
try:
|
||||||
|
await attendance_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -40,6 +64,16 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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
|
# Include API router
|
||||||
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from .models import (
|
|||||||
VolunteerRole,
|
VolunteerRole,
|
||||||
VolunteerAssignment,
|
VolunteerAssignment,
|
||||||
VolunteerSchedule,
|
VolunteerSchedule,
|
||||||
|
ProfileQuestion,
|
||||||
|
UserProfileAnswer,
|
||||||
Certificate,
|
Certificate,
|
||||||
File,
|
File,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -36,6 +38,8 @@ __all__ = [
|
|||||||
"VolunteerRole",
|
"VolunteerRole",
|
||||||
"VolunteerAssignment",
|
"VolunteerAssignment",
|
||||||
"VolunteerSchedule",
|
"VolunteerSchedule",
|
||||||
|
"ProfileQuestion",
|
||||||
|
"UserProfileAnswer",
|
||||||
"Certificate",
|
"Certificate",
|
||||||
"File",
|
"File",
|
||||||
"Notification",
|
"Notification",
|
||||||
|
|||||||
+221
-30
@@ -1,11 +1,11 @@
|
|||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
|
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
|
||||||
Float, Text, ForeignKey, Date
|
Float, Text, ForeignKey, Date, UniqueConstraint
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
|
||||||
import enum
|
import enum
|
||||||
from ..core.database import Base
|
from ..core.database import Base
|
||||||
|
from ..core.datetime import utc_now
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
class UserRole(str, enum.Enum):
|
||||||
@@ -49,6 +49,37 @@ class RSVPStatus(str, enum.Enum):
|
|||||||
MAYBE = "maybe"
|
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):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -60,9 +91,10 @@ class User(Base):
|
|||||||
phone = Column(String(20), nullable=True)
|
phone = Column(String(20), nullable=True)
|
||||||
address = Column(Text, nullable=True)
|
address = Column(Text, nullable=True)
|
||||||
role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False)
|
role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False)
|
||||||
|
volunteer_level = Column(String(50), nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
last_login = Column(DateTime, nullable=True)
|
last_login = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -71,6 +103,56 @@ class User(Base):
|
|||||||
event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan")
|
event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan")
|
||||||
volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan")
|
volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan")
|
||||||
certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan")
|
certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
profile_answers = relationship(
|
||||||
|
"UserProfileAnswer",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
foreign_keys="UserProfileAnswer.user_id"
|
||||||
|
)
|
||||||
|
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):
|
class MembershipTier(Base):
|
||||||
@@ -82,8 +164,8 @@ class MembershipTier(Base):
|
|||||||
annual_fee = Column(Float, nullable=False)
|
annual_fee = Column(Float, nullable=False)
|
||||||
benefits = Column(Text, nullable=True)
|
benefits = Column(Text, nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
memberships = relationship("Membership", back_populates="tier")
|
memberships = relationship("Membership", back_populates="tier")
|
||||||
@@ -99,8 +181,8 @@ class Membership(Base):
|
|||||||
start_date = Column(Date, nullable=False)
|
start_date = Column(Date, nullable=False)
|
||||||
end_date = Column(Date, nullable=False)
|
end_date = Column(Date, nullable=False)
|
||||||
auto_renew = Column(Boolean, default=False, nullable=False)
|
auto_renew = Column(Boolean, default=False, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", back_populates="memberships")
|
user = relationship("User", back_populates="memberships")
|
||||||
@@ -120,8 +202,8 @@ class Payment(Base):
|
|||||||
transaction_id = Column(String(255), nullable=True)
|
transaction_id = Column(String(255), nullable=True)
|
||||||
payment_date = Column(DateTime, nullable=True)
|
payment_date = Column(DateTime, nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", back_populates="payments")
|
user = relationship("User", back_populates="payments")
|
||||||
@@ -140,8 +222,8 @@ class Event(Base):
|
|||||||
max_attendees = Column(Integer, nullable=True)
|
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)
|
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_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
|
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)
|
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)
|
attended = Column(Boolean, default=False, nullable=False)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
event = relationship("Event", back_populates="rsvps")
|
event = relationship("Event", back_populates="rsvps")
|
||||||
user = relationship("User", back_populates="event_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):
|
class VolunteerRole(Base):
|
||||||
__tablename__ = "volunteer_roles"
|
__tablename__ = "volunteer_roles"
|
||||||
|
|
||||||
@@ -171,8 +362,8 @@ class VolunteerRole(Base):
|
|||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan")
|
assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan")
|
||||||
@@ -187,8 +378,8 @@ class VolunteerAssignment(Base):
|
|||||||
assigned_date = Column(Date, nullable=False)
|
assigned_date = Column(Date, nullable=False)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", back_populates="volunteer_assignments")
|
user = relationship("User", back_populates="volunteer_assignments")
|
||||||
@@ -207,8 +398,8 @@ class VolunteerSchedule(Base):
|
|||||||
location = Column(String(255), nullable=True)
|
location = Column(String(255), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
completed = Column(Boolean, default=False, nullable=False)
|
completed = Column(Boolean, default=False, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
assignment = relationship("VolunteerAssignment", back_populates="schedules")
|
assignment = relationship("VolunteerAssignment", back_populates="schedules")
|
||||||
@@ -226,8 +417,8 @@ class Certificate(Base):
|
|||||||
certificate_number = Column(String(100), nullable=True)
|
certificate_number = Column(String(100), nullable=True)
|
||||||
file_path = Column(String(500), nullable=True)
|
file_path = Column(String(500), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", back_populates="certificates")
|
user = relationship("User", back_populates="certificates")
|
||||||
@@ -245,8 +436,8 @@ class File(Base):
|
|||||||
min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True)
|
min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Notification(Base):
|
class Notification(Base):
|
||||||
@@ -259,7 +450,7 @@ class Notification(Base):
|
|||||||
email_sent = Column(Boolean, default=False, nullable=False)
|
email_sent = Column(Boolean, default=False, nullable=False)
|
||||||
sent_at = Column(DateTime, nullable=True)
|
sent_at = Column(DateTime, nullable=True)
|
||||||
error_message = Column(Text, 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):
|
class PasswordResetToken(Base):
|
||||||
@@ -270,7 +461,7 @@ class PasswordResetToken(Base):
|
|||||||
token = Column(String(255), unique=True, nullable=False, index=True)
|
token = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
expires_at = Column(DateTime, nullable=False)
|
expires_at = Column(DateTime, nullable=False)
|
||||||
used = Column(Boolean, default=False, 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
|
# Relationships
|
||||||
user = relationship("User", backref="password_reset_tokens")
|
user = relationship("User", backref="password_reset_tokens")
|
||||||
@@ -287,8 +478,8 @@ class EmailTemplate(Base):
|
|||||||
text_body = Column(Text, nullable=True)
|
text_body = Column(Text, nullable=True)
|
||||||
variables = Column(Text, nullable=True) # JSON string of available variables
|
variables = Column(Text, nullable=True) # JSON string of available variables
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class BounceType(str, enum.Enum):
|
class BounceType(str, enum.Enum):
|
||||||
@@ -308,5 +499,5 @@ class EmailBounce(Base):
|
|||||||
smtp2go_message_id = Column(String(255), nullable=True, index=True)
|
smtp2go_message_id = Column(String(255), nullable=True, index=True)
|
||||||
bounce_date = Column(DateTime, nullable=False)
|
bounce_date = Column(DateTime, nullable=False)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=utc_now, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||||
|
|||||||
@@ -37,6 +37,36 @@ from .schemas import (
|
|||||||
EventRSVPBase,
|
EventRSVPBase,
|
||||||
EventRSVPUpdate,
|
EventRSVPUpdate,
|
||||||
EventRSVPResponse,
|
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__ = [
|
__all__ = [
|
||||||
@@ -78,4 +108,34 @@ __all__ = [
|
|||||||
"EventRSVPBase",
|
"EventRSVPBase",
|
||||||
"EventRSVPUpdate",
|
"EventRSVPUpdate",
|
||||||
"EventRSVPResponse",
|
"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 pydantic import BaseModel, EmailStr, Field, ConfigDict, field_serializer, field_validator
|
||||||
from typing import Optional
|
from typing import Optional, Literal, Any
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
|
from ..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
|
# User Schemas
|
||||||
class UserBase(BaseModel):
|
class UserBase(UTCBaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
first_name: str = Field(..., min_length=1, max_length=100)
|
first_name: str = Field(..., min_length=1, max_length=100)
|
||||||
last_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)
|
password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(UTCBaseModel):
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
last_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
|
phone: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
role: Optional[UserRole] = None
|
role: Optional[UserRole] = None
|
||||||
|
volunteer_level: Optional[str] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
@@ -31,6 +64,7 @@ class UserResponse(UserBase):
|
|||||||
|
|
||||||
id: int
|
id: int
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
volunteer_level: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[datetime] = None
|
||||||
@@ -41,37 +75,37 @@ class UserInDB(UserResponse):
|
|||||||
|
|
||||||
|
|
||||||
# Authentication Schemas
|
# Authentication Schemas
|
||||||
class Token(BaseModel):
|
class Token(UTCBaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
class TokenData(UTCBaseModel):
|
||||||
user_id: Optional[int] = None
|
user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(UTCBaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
# Password Reset Schemas
|
# Password Reset Schemas
|
||||||
class ForgotPasswordRequest(BaseModel):
|
class ForgotPasswordRequest(UTCBaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordRequest(BaseModel):
|
class ResetPasswordRequest(UTCBaseModel):
|
||||||
token: str = Field(..., min_length=1)
|
token: str = Field(..., min_length=1)
|
||||||
new_password: str = Field(..., min_length=8)
|
new_password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(UTCBaseModel):
|
||||||
current_password: str = Field(..., min_length=1)
|
current_password: str = Field(..., min_length=1)
|
||||||
new_password: str = Field(..., min_length=8)
|
new_password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
# Membership Tier Schemas
|
# Membership Tier Schemas
|
||||||
class MembershipTierBase(BaseModel):
|
class MembershipTierBase(UTCBaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
annual_fee: float = Field(..., ge=0)
|
annual_fee: float = Field(..., ge=0)
|
||||||
@@ -82,7 +116,7 @@ class MembershipTierCreate(MembershipTierBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MembershipTierUpdate(BaseModel):
|
class MembershipTierUpdate(UTCBaseModel):
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
annual_fee: Optional[float] = Field(None, ge=0)
|
annual_fee: Optional[float] = Field(None, ge=0)
|
||||||
@@ -99,7 +133,7 @@ class MembershipTierResponse(MembershipTierBase):
|
|||||||
|
|
||||||
|
|
||||||
# Membership Schemas
|
# Membership Schemas
|
||||||
class MembershipBase(BaseModel):
|
class MembershipBase(UTCBaseModel):
|
||||||
tier_id: int
|
tier_id: int
|
||||||
auto_renew: bool = False
|
auto_renew: bool = False
|
||||||
|
|
||||||
@@ -109,14 +143,14 @@ class MembershipCreate(MembershipBase):
|
|||||||
end_date: date
|
end_date: date
|
||||||
|
|
||||||
|
|
||||||
class MembershipUpdate(BaseModel):
|
class MembershipUpdate(UTCBaseModel):
|
||||||
tier_id: Optional[int] = None
|
tier_id: Optional[int] = None
|
||||||
status: Optional[MembershipStatus] = None
|
status: Optional[MembershipStatus] = None
|
||||||
end_date: Optional[date] = None
|
end_date: Optional[date] = None
|
||||||
auto_renew: Optional[bool] = None
|
auto_renew: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class MembershipResponse(BaseModel):
|
class MembershipResponse(UTCBaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
@@ -131,7 +165,7 @@ class MembershipResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Payment Schemas
|
# Payment Schemas
|
||||||
class PaymentBase(BaseModel):
|
class PaymentBase(UTCBaseModel):
|
||||||
amount: float = Field(..., gt=0)
|
amount: float = Field(..., gt=0)
|
||||||
payment_method: PaymentMethod
|
payment_method: PaymentMethod
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
@@ -141,14 +175,14 @@ class PaymentCreate(PaymentBase):
|
|||||||
membership_id: Optional[int] = None
|
membership_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class PaymentUpdate(BaseModel):
|
class PaymentUpdate(UTCBaseModel):
|
||||||
status: Optional[PaymentStatus] = None
|
status: Optional[PaymentStatus] = None
|
||||||
transaction_id: Optional[str] = None
|
transaction_id: Optional[str] = None
|
||||||
payment_date: Optional[datetime] = None
|
payment_date: Optional[datetime] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PaymentResponse(BaseModel):
|
class PaymentResponse(UTCBaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
@@ -164,7 +198,7 @@ class PaymentResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Square Payment Schemas
|
# Square Payment Schemas
|
||||||
class SquarePaymentRequest(BaseModel):
|
class SquarePaymentRequest(UTCBaseModel):
|
||||||
"""Request schema for Square payment processing"""
|
"""Request schema for Square payment processing"""
|
||||||
source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK")
|
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")
|
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")
|
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"""
|
"""Response schema for Square payment"""
|
||||||
success: bool
|
success: bool
|
||||||
payment_id: Optional[str] = None
|
payment_id: Optional[str] = None
|
||||||
@@ -187,7 +221,7 @@ class SquarePaymentResponse(BaseModel):
|
|||||||
membership_id: Optional[int] = Field(None, description="Created membership ID")
|
membership_id: Optional[int] = Field(None, description="Created membership ID")
|
||||||
|
|
||||||
|
|
||||||
class SquareRefundRequest(BaseModel):
|
class SquareRefundRequest(UTCBaseModel):
|
||||||
"""Request schema for Square payment refund"""
|
"""Request schema for Square payment refund"""
|
||||||
payment_id: int = Field(..., description="Database payment ID")
|
payment_id: int = Field(..., description="Database payment ID")
|
||||||
amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)")
|
amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)")
|
||||||
@@ -195,13 +229,13 @@ class SquareRefundRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Message Response
|
# Message Response
|
||||||
class MessageResponse(BaseModel):
|
class MessageResponse(UTCBaseModel):
|
||||||
message: str
|
message: str
|
||||||
detail: Optional[str] = None
|
detail: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Email Template Schemas
|
# Email Template Schemas
|
||||||
class EmailTemplateBase(BaseModel):
|
class EmailTemplateBase(UTCBaseModel):
|
||||||
template_key: str
|
template_key: str
|
||||||
name: str
|
name: str
|
||||||
subject: str
|
subject: str
|
||||||
@@ -214,7 +248,7 @@ class EmailTemplateCreate(EmailTemplateBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class EmailTemplateUpdate(BaseModel):
|
class EmailTemplateUpdate(UTCBaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
subject: Optional[str] = None
|
subject: Optional[str] = None
|
||||||
html_body: Optional[str] = None
|
html_body: Optional[str] = None
|
||||||
@@ -233,7 +267,7 @@ class EmailTemplateResponse(EmailTemplateBase):
|
|||||||
|
|
||||||
|
|
||||||
# Event Schemas
|
# Event Schemas
|
||||||
class EventBase(BaseModel):
|
class EventBase(UTCBaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=255)
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
event_date: datetime
|
event_date: datetime
|
||||||
@@ -246,7 +280,7 @@ class EventCreate(EventBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class EventUpdate(BaseModel):
|
class EventUpdate(UTCBaseModel):
|
||||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
event_date: Optional[datetime] = None
|
event_date: Optional[datetime] = None
|
||||||
@@ -267,7 +301,7 @@ class EventResponse(EventBase):
|
|||||||
|
|
||||||
|
|
||||||
# Event RSVP Schemas
|
# Event RSVP Schemas
|
||||||
class EventRSVPBase(BaseModel):
|
class EventRSVPBase(UTCBaseModel):
|
||||||
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
|
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
@@ -285,3 +319,298 @@ class EventRSVPResponse(EventRSVPBase):
|
|||||||
attended: bool
|
attended: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Profile Question Schemas
|
||||||
|
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionOption(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 typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from ..core.datetime import to_utc_naive, utc_now
|
||||||
from ..models.models import EmailBounce, BounceType
|
from ..models.models import EmailBounce, BounceType
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
|
|
||||||
@@ -38,7 +39,9 @@ class BounceService:
|
|||||||
db = next(get_db())
|
db = next(get_db())
|
||||||
|
|
||||||
if bounce_date is None:
|
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
|
# Check if bounce already exists for this email and type
|
||||||
existing_bounce = db.query(EmailBounce).filter(
|
existing_bounce = db.query(EmailBounce).filter(
|
||||||
@@ -54,7 +57,7 @@ class BounceService:
|
|||||||
if smtp2go_message_id:
|
if smtp2go_message_id:
|
||||||
existing_bounce.smtp2go_message_id = smtp2go_message_id
|
existing_bounce.smtp2go_message_id = smtp2go_message_id
|
||||||
existing_bounce.bounce_date = bounce_date
|
existing_bounce.bounce_date = bounce_date
|
||||||
existing_bounce.updated_at = datetime.utcnow()
|
existing_bounce.updated_at = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(existing_bounce)
|
db.refresh(existing_bounce)
|
||||||
return existing_bounce
|
return existing_bounce
|
||||||
@@ -130,7 +133,7 @@ class BounceService:
|
|||||||
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
|
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
|
||||||
if bounce:
|
if bounce:
|
||||||
bounce.is_active = False
|
bounce.is_active = False
|
||||||
bounce.updated_at = datetime.utcnow()
|
bounce.updated_at = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -189,9 +192,10 @@ class BounceService:
|
|||||||
try:
|
try:
|
||||||
# SMTP2GO timestamps are typically Unix timestamps
|
# SMTP2GO timestamps are typically Unix timestamps
|
||||||
if isinstance(timestamp, (int, float)):
|
if isinstance(timestamp, (int, float)):
|
||||||
bounce_date = datetime.fromtimestamp(timestamp)
|
bounce_date = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||||
elif isinstance(timestamp, str):
|
elif isinstance(timestamp, str):
|
||||||
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
||||||
|
bounce_date = to_utc_naive(bounce_date)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -252,14 +256,14 @@ class BounceService:
|
|||||||
db = next(get_db())
|
db = next(get_db())
|
||||||
|
|
||||||
from datetime import timedelta
|
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
|
# Only deactivate soft bounces, keep hard bounces and complaints active
|
||||||
result = db.query(EmailBounce).filter(
|
result = db.query(EmailBounce).filter(
|
||||||
EmailBounce.bounce_type == BounceType.SOFT,
|
EmailBounce.bounce_type == BounceType.SOFT,
|
||||||
EmailBounce.is_active == True,
|
EmailBounce.is_active == True,
|
||||||
EmailBounce.bounce_date < cutoff_date
|
EmailBounce.bounce_date < cutoff_date
|
||||||
).update({'is_active': False, 'updated_at': datetime.utcnow()})
|
).update({'is_active': False, 'updated_at': utc_now()})
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import httpx
|
|||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
|
from ..core.datetime import utc_now
|
||||||
from ..models.models import EmailTemplate
|
from ..models.models import EmailTemplate
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..core.config import settings
|
from ..core.config import settings
|
||||||
@@ -147,7 +148,7 @@ class EmailService:
|
|||||||
"payment_amount": f"£{payment_amount:.2f}",
|
"payment_amount": f"£{payment_amount:.2f}",
|
||||||
"payment_method": payment_method,
|
"payment_method": payment_method,
|
||||||
"renewal_date": renewal_date,
|
"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
|
"app_name": settings.APP_NAME
|
||||||
}
|
}
|
||||||
return await self.send_templated_email("membership_activation", to_email, variables, db)
|
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
|
python-multipart==0.0.6
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlalchemy==2.0.23
|
sqlalchemy==2.0.49
|
||||||
pymysql==1.1.0
|
pymysql==1.1.0
|
||||||
cryptography==41.0.7
|
cryptography==41.0.7
|
||||||
alembic==1.13.0
|
alembic==1.13.0
|
||||||
@@ -28,3 +28,6 @@ email-validator==2.1.0
|
|||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
pytest==8.3.4
|
||||||
|
|||||||
@@ -17,10 +17,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"vite": "^5.0.5"
|
"vite": "^5.0.5",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3985
-164
File diff suppressed because it is too large
Load Diff
+44
-14
@@ -1,31 +1,61 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
|
import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
|
||||||
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
|
import { ConfirmProvider } from './contexts/ConfirmContext';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import ForgotPassword from './pages/ForgotPassword';
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import EmailTemplates from './pages/EmailTemplates';
|
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||||
import MembershipTiers from './pages/MembershipTiers';
|
import TermsOfService from './pages/TermsOfService';
|
||||||
import BounceManagement from './pages/BounceManagement';
|
import AppFooter from './components/layout/AppFooter';
|
||||||
|
import CookieBanner from './components/layout/CookieBanner';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
|
const [cookieDismissed, setCookieDismissed] = useState(
|
||||||
|
() => localStorage.getItem('cookie_notice_dismissed') === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissCookies = () => {
|
||||||
|
localStorage.setItem('cookie_notice_dismissed', 'true');
|
||||||
|
setCookieDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeatureFlagProvider>
|
<FeatureFlagProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<ConfirmProvider>
|
||||||
<Route path="/" element={<Navigate to="/login" />} />
|
<ToastProvider>
|
||||||
<Route path="/register" element={<Register />} />
|
<div className="app-shell">
|
||||||
<Route path="/login" element={<Login />} />
|
<main className="app-main">
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Routes>
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/" element={<Navigate to="/login" />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/email-templates" element={<EmailTemplates />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/membership-tiers" element={<MembershipTiers />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/bounce-management" element={<BounceManagement />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
</Routes>
|
<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>
|
</BrowserRouter>
|
||||||
</FeatureFlagProvider>
|
</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 axios from 'axios';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../contexts/ConfirmContext';
|
||||||
|
import { formatLondonDateTime, utcMillis } from '../utils/timezone';
|
||||||
|
|
||||||
interface BounceRecord {
|
interface BounceRecord {
|
||||||
id: number;
|
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 [bounces, setBounces] = useState<BounceRecord[]>([]);
|
||||||
const [stats, setStats] = useState<BounceStats | null>(null);
|
const [stats, setStats] = useState<BounceStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchEmail, setSearchEmail] = useState('');
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [filteredBounces, setFilteredBounces] = useState<BounceRecord[]>([]);
|
const [sortKey, setSortKey] = useState<BounceSortKey>('date');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBounces();
|
fetchBounces();
|
||||||
@@ -35,16 +49,10 @@ const BounceManagement: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchEmail.trim() === '') {
|
if (cleanupToken > 0) {
|
||||||
setFilteredBounces(bounces);
|
void handleCleanupOldBounces();
|
||||||
} else {
|
|
||||||
setFilteredBounces(
|
|
||||||
bounces.filter(bounce =>
|
|
||||||
bounce.email.toLowerCase().includes(searchEmail.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [bounces, searchEmail]);
|
}, [cleanupToken]);
|
||||||
|
|
||||||
const fetchBounces = async () => {
|
const fetchBounces = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -73,264 +81,197 @@ const BounceManagement: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivateBounce = async (bounceId: number) => {
|
const handleDeactivateBounce = async (bounceId: number) => {
|
||||||
if (!window.confirm('Are you sure you want to deactivate this bounce record?')) {
|
const confirmed = await confirm({
|
||||||
return;
|
title: 'Resolve bounce record',
|
||||||
}
|
message: 'Are you sure you want to deactivate this bounce record?',
|
||||||
|
confirmLabel: 'Resolve',
|
||||||
|
tone: 'danger'
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
await axios.delete(`/api/v1/email/bounces/${bounceId}`, {
|
await axios.delete(`/api/v1/email/bounces/${bounceId}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
fetchBounces(); // Refresh the list
|
fetchBounces();
|
||||||
fetchStats(); // Refresh stats
|
fetchStats();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deactivating bounce:', error);
|
console.error('Error deactivating bounce:', error);
|
||||||
alert('Failed to deactivate bounce record');
|
toast.error('Failed to deactivate bounce record.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCleanupOldBounces = async () => {
|
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.')) {
|
const confirmed = await confirm({
|
||||||
return;
|
title: 'Cleanup old bounces',
|
||||||
}
|
message: 'Are you sure you want to cleanup old soft bounces?',
|
||||||
|
confirmLabel: 'Cleanup',
|
||||||
|
tone: 'danger'
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await axios.post('/api/v1/email/bounces/cleanup', {}, {
|
const response = await axios.post('/api/v1/email/bounces/cleanup', {}, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
alert(response.data.message);
|
toast.success(response.data.message);
|
||||||
fetchBounces(); // Refresh the list
|
fetchBounces();
|
||||||
fetchStats(); // Refresh stats
|
fetchStats();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cleaning up bounces:', 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) => {
|
const filteredBounces = bounces.filter((bounce) =>
|
||||||
switch (type) {
|
searchTerm.trim() === '' ? true : bounce.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
case 'hard': return '#dc3545';
|
);
|
||||||
case 'soft': return '#ffc107';
|
|
||||||
case 'complaint': return '#fd7e14';
|
const sortedBounces = [...filteredBounces].sort((left, right) => {
|
||||||
case 'unsubscribe': return '#6c757d';
|
const compareValues = (a: string | number, b: string | number) => {
|
||||||
default: return '#6c757d';
|
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) => {
|
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
|
||||||
return new Date(dateString).toLocaleString();
|
<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) {
|
if (loading) {
|
||||||
return (
|
return <p className="admin-empty">Loading bounce data...</p>;
|
||||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
|
||||||
<div>Loading bounce data...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Statistics Cards */}
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<div style={{
|
<div className="admin-stat-grid">
|
||||||
display: 'grid',
|
<div className="admin-stat-card"><span>Total Bounces</span><strong>{stats.total_bounces}</strong></div>
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
<div className="admin-stat-card attention"><span>Active Bounces</span><strong>{stats.active_bounces}</strong></div>
|
||||||
gap: '20px',
|
<div className="admin-stat-card"><span>Hard Bounces</span><strong>{stats.bounce_types.hard}</strong></div>
|
||||||
marginBottom: '30px'
|
<div className="admin-stat-card"><span>Soft Bounces</span><strong>{stats.bounce_types.soft}</strong></div>
|
||||||
}}>
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
<div className="admin-table-shell">
|
||||||
<div style={{
|
<div className="admin-table-wrap">
|
||||||
display: 'flex',
|
<table className="admin-table">
|
||||||
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'
|
|
||||||
}}>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: '#f8f9fa' }}>
|
<tr>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Email</th>
|
<th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Type</th>
|
<button type="button" className={sortKey === 'email' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('email')}>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Reason</th>
|
<span>Email</span>{renderSortArrow(sortKey === 'email', sortDirection)}
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Date</th>
|
</button>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Status</th>
|
</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Actions</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredBounces.length === 0 ? (
|
{paginatedBounces.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} style={{
|
<td colSpan={6} className="admin-table-empty">
|
||||||
padding: '40px',
|
{searchTerm ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
||||||
textAlign: 'center',
|
|
||||||
color: '#6c757d',
|
|
||||||
fontStyle: 'italic'
|
|
||||||
}}>
|
|
||||||
{searchEmail ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredBounces.map((bounce) => (
|
paginatedBounces.map((bounce) => (
|
||||||
<tr key={bounce.id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
<tr key={bounce.id}>
|
||||||
<td style={{ padding: '12px' }}>
|
<td>
|
||||||
<div style={{ fontWeight: '500' }}>{bounce.email}</div>
|
<strong>{bounce.email}</strong>
|
||||||
{bounce.smtp2go_message_id && (
|
{bounce.smtp2go_message_id && <span className="muted-line">ID: {bounce.smtp2go_message_id}</span>}
|
||||||
<div style={{ fontSize: '0.8rem', color: '#6c757d' }}>
|
|
||||||
ID: {bounce.smtp2go_message_id}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td>
|
||||||
<span style={{
|
<span className={`status-badge ${
|
||||||
backgroundColor: getBounceTypeColor(bounce.bounce_type),
|
bounce.bounce_type === 'soft' ? 'status-pending' :
|
||||||
color: 'white',
|
bounce.bounce_type === 'hard' ? 'status-expired' :
|
||||||
padding: '4px 8px',
|
'status-active'
|
||||||
borderRadius: '4px',
|
}`}>
|
||||||
fontSize: '0.8rem',
|
{bounce.bounce_type.toUpperCase()}
|
||||||
fontWeight: 'bold',
|
|
||||||
textTransform: 'uppercase'
|
|
||||||
}}>
|
|
||||||
{bounce.bounce_type}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px', maxWidth: '300px' }}>
|
<td>{bounce.bounce_reason || 'No reason provided'}</td>
|
||||||
<div style={{
|
<td>{formatDate(bounce.bounce_date)}</td>
|
||||||
overflow: 'hidden',
|
<td>
|
||||||
textOverflow: 'ellipsis',
|
<span className={`status-badge ${bounce.is_active ? 'status-expired' : 'status-active'}`}>
|
||||||
whiteSpace: 'nowrap'
|
{bounce.is_active ? 'ACTIVE' : 'RESOLVED'}
|
||||||
}}>
|
|
||||||
{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'}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td>
|
||||||
{bounce.is_active && (
|
{bounce.is_active && (
|
||||||
<button
|
<button className="btn btn-primary" onClick={() => handleDeactivateBounce(bounce.id)}>
|
||||||
onClick={() => handleDeactivateBounce(bounce.id)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
backgroundColor: '#28a745',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.8rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Resolve
|
Resolve
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -341,6 +282,17 @@ const BounceManagement: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
|
||||||
interface EmailTemplate {
|
interface EmailTemplate {
|
||||||
template_key: string;
|
template_key: string;
|
||||||
@@ -7,22 +8,55 @@ interface EmailTemplate {
|
|||||||
subject: string;
|
subject: string;
|
||||||
html_body: string;
|
html_body: string;
|
||||||
text_body: string;
|
text_body: string;
|
||||||
variables: string; // This comes as JSON string from backend
|
variables: string;
|
||||||
is_active: boolean;
|
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 [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchTemplates();
|
void fetchTemplates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshToken > 0) {
|
||||||
|
void fetchTemplates();
|
||||||
|
}
|
||||||
|
}, [refreshToken]);
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await axios.get('/api/v1/email-templates/', {
|
const response = await axios.get('/api/v1/email-templates/', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
@@ -30,149 +64,202 @@ const EmailTemplateManagement: React.FC = () => {
|
|||||||
setTemplates(response.data);
|
setTemplates(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching email templates:', error);
|
console.error('Error fetching email templates:', error);
|
||||||
|
toast.error('Failed to load email templates.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditTemplate = (template: EmailTemplate) => {
|
|
||||||
setEditingTemplate(template);
|
|
||||||
setShowEditForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
|
const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
|
await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
setShowEditForm(false);
|
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
fetchTemplates(); // Refresh the list
|
toast.success('Email template updated.');
|
||||||
|
void fetchTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating email template:', error);
|
console.error('Error updating email template:', error);
|
||||||
|
toast.error('Failed to update email template.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const filteredTemplates = useMemo(() => {
|
||||||
setShowEditForm(false);
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
setEditingTemplate(null);
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading email templates...</div>;
|
return <p className="admin-empty">Loading email templates...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
|
<div>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div className="admin-table-shell">
|
||||||
<button
|
<div className="admin-table-wrap">
|
||||||
onClick={fetchTemplates}
|
<table className="admin-table">
|
||||||
style={{
|
<thead>
|
||||||
padding: '8px 16px',
|
<tr>
|
||||||
backgroundColor: '#007bff',
|
<th>
|
||||||
color: 'white',
|
<button type="button" className={sortKey === 'name' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('name')}>
|
||||||
border: 'none',
|
<span>Template</span>{renderSortArrow(sortKey === 'name', sortDirection)}
|
||||||
borderRadius: '4px',
|
</button>
|
||||||
cursor: 'pointer'
|
</th>
|
||||||
}}
|
<th>
|
||||||
>
|
<button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
|
||||||
Refresh Templates
|
<span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
{editingTemplate && (
|
||||||
{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 && (
|
|
||||||
<EmailTemplateEditForm
|
<EmailTemplateEditForm
|
||||||
template={editingTemplate}
|
template={editingTemplate}
|
||||||
onSave={handleSaveTemplate}
|
onSave={handleSaveTemplate}
|
||||||
onCancel={handleCancelEdit}
|
onCancel={() => setEditingTemplate(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,6 +273,7 @@ interface EmailTemplateEditFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
|
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
|
||||||
|
const [previewMode, setPreviewMode] = useState<'rendered' | 'html' | 'text'>('rendered');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: template.name,
|
name: template.name,
|
||||||
subject: template.subject,
|
subject: template.subject,
|
||||||
@@ -202,198 +290,186 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
|
|||||||
is_active: template.is_active
|
is_active: template.is_active
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (field: keyof EmailTemplate, value: any) => {
|
const handleChange = (field: keyof typeof formData, value: any) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dataToSave = {
|
onSave({
|
||||||
template_key: template.template_key,
|
template_key: template.template_key,
|
||||||
...formData,
|
...formData,
|
||||||
variables: JSON.stringify(formData.variables)
|
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}>
|
const previewDocument = useMemo(() => {
|
||||||
<div style={{ marginBottom: '15px' }}>
|
return `
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<!doctype html>
|
||||||
Template Key:
|
<html lang="en">
|
||||||
</label>
|
<head>
|
||||||
<input
|
<meta charset="utf-8" />
|
||||||
type="text"
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
value={template.template_key}
|
<style>
|
||||||
disabled
|
body {
|
||||||
style={{
|
margin: 0;
|
||||||
width: '100%',
|
padding: 24px;
|
||||||
padding: '8px',
|
background: #ffffff;
|
||||||
border: '1px solid #ddd',
|
color: #111111;
|
||||||
borderRadius: '4px',
|
font-family: Arial, sans-serif;
|
||||||
backgroundColor: '#f5f5f5'
|
}
|
||||||
}}
|
</style>
|
||||||
/>
|
</head>
|
||||||
<small style={{ color: '#666' }}>Template key cannot be changed</small>
|
<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>
|
||||||
|
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<div className="drawer-section">
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<div className="drawer-section-header">
|
||||||
Name:
|
<div>
|
||||||
</label>
|
<h4>Preview</h4>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
</div>
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<div className="email-preview-tabs" role="tablist" aria-label="Email preview mode">
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<button
|
||||||
Subject:
|
type="button"
|
||||||
</label>
|
className={previewMode === 'rendered' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||||
<input
|
onClick={() => setPreviewMode('rendered')}
|
||||||
type="text"
|
>
|
||||||
value={formData.subject}
|
Rendered
|
||||||
onChange={(e) => handleChange('subject', e.target.value)}
|
</button>
|
||||||
style={{
|
<button
|
||||||
width: '100%',
|
type="button"
|
||||||
padding: '8px',
|
className={previewMode === 'html' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||||
border: '1px solid #ddd',
|
onClick={() => setPreviewMode('html')}
|
||||||
borderRadius: '4px'
|
>
|
||||||
}}
|
HTML
|
||||||
required
|
</button>
|
||||||
/>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
className={previewMode === 'text' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||||
|
onClick={() => setPreviewMode('text')}
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '15px' }}>
|
{previewMode === 'rendered' && (
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<div className="email-preview-frame-shell">
|
||||||
Variables (comma-separated):
|
<iframe
|
||||||
</label>
|
title={`${template.name} preview`}
|
||||||
<input
|
className="email-preview-frame"
|
||||||
type="text"
|
srcDoc={previewDocument}
|
||||||
value={formData.variables.join(', ')}
|
/>
|
||||||
onChange={(e) => handleChange('variables', e.target.value.split(',').map(v => v.trim()))}
|
</div>
|
||||||
style={{
|
)}
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '15px' }}>
|
{previewMode === 'html' && (
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<pre className="email-preview-code">{formData.html_body}</pre>
|
||||||
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>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '15px' }}>
|
{previewMode === 'text' && (
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<pre className="email-preview-code">{formData.text_body}</pre>
|
||||||
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
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
</aside>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,29 +5,25 @@ const FeatureFlagStatus: React.FC = () => {
|
|||||||
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
||||||
|
|
||||||
if (loading) {
|
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) {
|
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) {
|
if (!flags) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReload = async () => {
|
|
||||||
try {
|
|
||||||
await reloadFlags();
|
|
||||||
console.log('Feature flags reloaded');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to reload feature flags:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: '20px' }}>
|
<div className="admin-surface" style={{ marginBottom: '20px' }}>
|
||||||
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
|
<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' }}>
|
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
||||||
{Object.entries(flags.flags).map(([name, value]) => (
|
{Object.entries(flags.flags).map(([name, value]) => (
|
||||||
@@ -37,23 +33,28 @@ const FeatureFlagStatus: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '8px 12px',
|
padding: '10px 12px',
|
||||||
backgroundColor: '#f5f5f5',
|
background: 'rgba(16,18,22,0.72)',
|
||||||
borderRadius: '4px',
|
borderTop: '1px solid rgba(64,71,80,0.55)',
|
||||||
fontSize: '14px'
|
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' }}>
|
<span style={{ fontWeight: 500, color: '#E6EBF2' }}>
|
||||||
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
|
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '12px',
|
borderRadius: '999px',
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
fontWeight: '500',
|
fontWeight: 500,
|
||||||
backgroundColor: value ? '#4CAF50' : '#f44336',
|
background: value ? 'rgba(47,162,82,.13)' : 'rgba(92,31,33,.4)',
|
||||||
color: 'white'
|
color: value ? '#2FA252' : '#EE6368',
|
||||||
|
border: `1px solid ${value ? 'rgba(47,162,82,.36)' : 'rgba(238,99,104,.42)'}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{String(value)}
|
{String(value)}
|
||||||
@@ -62,16 +63,12 @@ const FeatureFlagStatus: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button className="btn btn-secondary" onClick={reloadFlags} style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={handleReload}
|
|
||||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
|
||||||
>
|
|
||||||
Reload Flags
|
Reload Flags
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
|
<p style={{ fontSize: '12px', color: '#8D96A3', marginTop: '12px', marginBottom: 0 }}>
|
||||||
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
|
Feature flags are loaded from environment variables. Changes require updating the environment and reloading.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
||||||
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||||
import SquarePaymentNew from './SquarePaymentNew';
|
import SquarePaymentNew from './SquarePaymentNew';
|
||||||
|
import { londonTodayDateInput } from '../utils/timezone';
|
||||||
|
|
||||||
interface MembershipSetupProps {
|
interface MembershipSetupProps {
|
||||||
onMembershipCreated: () => void;
|
onMembershipCreated: () => void;
|
||||||
@@ -85,8 +86,10 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startDate = new Date().toISOString().split('T')[0];
|
const startDate = londonTodayDateInput();
|
||||||
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
const endDateValue = new Date(`${startDate}T00:00:00Z`);
|
||||||
|
endDateValue.setUTCFullYear(endDateValue.getUTCFullYear() + 1);
|
||||||
|
const endDate = endDateValue.toISOString().split('T')[0];
|
||||||
|
|
||||||
const membershipData: MembershipCreateData = {
|
const membershipData: MembershipCreateData = {
|
||||||
tier_id: selectedTier.id,
|
tier_id: selectedTier.id,
|
||||||
@@ -112,47 +115,38 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
|
|
||||||
if (step === 'select') {
|
if (step === 'select') {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card member-card member-membership-setup">
|
||||||
<h3 style={{ marginBottom: '16px' }}>Choose Your Membership</h3>
|
<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>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '16px' }}>
|
<div className="membership-tier-grid">
|
||||||
{tiers.map(tier => (
|
{tiers.map(tier => (
|
||||||
<div
|
<div
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
style={{
|
className="membership-tier-card"
|
||||||
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';
|
|
||||||
}}
|
|
||||||
onClick={() => handleTierSelect(tier)}
|
onClick={() => handleTierSelect(tier)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
<div className="membership-tier-header">
|
||||||
<h4 style={{ margin: 0, color: '#0066cc' }}>{tier.name}</h4>
|
<h4>{tier.name}</h4>
|
||||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#0066cc' }}>
|
<span className="membership-tier-price">
|
||||||
£{tier.annual_fee.toFixed(2)}/year
|
£{tier.annual_fee.toFixed(2)}/year
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: '8px 0', color: '#666', fontSize: '14px' }}>{tier.description}</p>
|
<p className="membership-tier-description">{tier.description}</p>
|
||||||
<div style={{ backgroundColor: '#f5f5f5', padding: '12px', borderRadius: '4px' }}>
|
<div className="membership-tier-benefits">
|
||||||
<strong>Benefits:</strong>
|
<strong>Benefits:</strong>
|
||||||
<p style={{ marginTop: '4px', fontSize: '14px' }}>{tier.benefits}</p>
|
<p>{tier.benefits}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -167,12 +161,17 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
|
|
||||||
if (step === 'payment') {
|
if (step === 'payment') {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card member-card member-membership-setup">
|
||||||
<h3 style={{ marginBottom: '16px' }}>Complete Payment</h3>
|
<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>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
{selectedTier && (
|
{selectedTier && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div className="membership-summary-panel">
|
||||||
<h4>Selected Membership: {selectedTier.name}</h4>
|
<h4>Selected Membership: {selectedTier.name}</h4>
|
||||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||||
<p><strong>Benefits:</strong> {selectedTier.benefits}</p>
|
<p><strong>Benefits:</strong> {selectedTier.benefits}</p>
|
||||||
@@ -180,25 +179,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!paymentMethod && (
|
{!paymentMethod && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div className="membership-payment-stage">
|
||||||
<h4 style={{ marginBottom: '16px' }}>Choose Payment Method</h4>
|
<h4 className="membership-payment-heading">Choose Payment Method</h4>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '12px' }}>
|
<div className="membership-payment-options">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handlePaymentMethodSelect('square')}
|
onClick={() => handlePaymentMethodSelect('square')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
style={{ textAlign: 'left' }}
|
||||||
padding: '16px',
|
|
||||||
textAlign: 'left',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="membership-payment-option-copy">
|
||||||
<strong>Credit/Debit Card</strong>
|
<strong>Credit/Debit Card</strong>
|
||||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
<div>
|
||||||
Pay securely with Square
|
Pay securely with Square
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,17 +203,11 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => handlePaymentMethodSelect('cash')}
|
onClick={() => handlePaymentMethodSelect('cash')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
style={{ textAlign: 'left' }}
|
||||||
padding: '16px',
|
|
||||||
textAlign: 'left',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="membership-payment-option-copy">
|
||||||
<strong>Cash Payment</strong>
|
<strong>Cash Payment</strong>
|
||||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
<div>
|
||||||
Pay in person or by check
|
Pay in person or by check
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +216,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -250,7 +237,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
onPaymentSuccess={handleSquarePaymentSuccess}
|
onPaymentSuccess={handleSquarePaymentSuccess}
|
||||||
onPaymentError={handleSquarePaymentError}
|
onPaymentError={handleSquarePaymentError}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -268,26 +255,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
|
|
||||||
{paymentMethod === 'cash' && createdMembershipId && (
|
{paymentMethod === 'cash' && createdMembershipId && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div className="membership-cash-notice">
|
||||||
backgroundColor: '#fff3cd',
|
|
||||||
border: '1px solid #ffeaa7',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '16px',
|
|
||||||
marginBottom: '20px'
|
|
||||||
}}>
|
|
||||||
<strong>Cash Payment Selected</strong>
|
<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.
|
Your membership will be marked as pending until an administrator confirms payment receipt.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div className="membership-action-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handleCashPayment}
|
onClick={handleCashPayment}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ marginRight: '10px' }}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
||||||
</button>
|
</button>
|
||||||
@@ -314,13 +294,18 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
const isCashPayment = paymentMethod === 'cash';
|
const isCashPayment = paymentMethod === 'cash';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card member-card member-membership-setup">
|
||||||
<h3 style={{ marginBottom: '16px' }}>
|
<div className="member-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="member-card-kicker">Membership Setup</p>
|
||||||
|
<h3>
|
||||||
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedTier && (
|
{selectedTier && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div className="membership-summary-panel">
|
||||||
<h4>Your Membership Details:</h4>
|
<h4>Your Membership Details:</h4>
|
||||||
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
||||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</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'}
|
{isCashPayment ? 'Pending' : 'Active'}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
|
<p className="membership-confirm-copy">
|
||||||
{isCashPayment
|
{isCashPayment
|
||||||
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
|
? '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.'
|
: '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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authService, User } from '../services/membershipService';
|
import { authService, User } from '../services/membershipService';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { formatLondonDate } from '../utils/timezone';
|
||||||
|
|
||||||
interface ProfileMenuProps {
|
interface ProfileMenuProps {
|
||||||
userName: string;
|
userName: string;
|
||||||
@@ -9,7 +11,7 @@ interface ProfileMenuProps {
|
|||||||
onEditProfile?: () => void;
|
onEditProfile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => {
|
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -38,146 +40,55 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onE
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => formatLondonDate(dateString);
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ position: 'relative' }} ref={menuRef}>
|
<div className="profile-menu" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
|
className="profile-menu-trigger"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '16px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span>{userName}</span>
|
<span>{userName}</span>
|
||||||
<span style={{ fontSize: '12px' }}>▼</span>
|
<span className="profile-menu-chevron">▼</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={dropdownStyle}>
|
<div className="profile-menu-dropdown">
|
||||||
{/* Profile Details Section */}
|
|
||||||
{user && (
|
{user && (
|
||||||
<div style={{
|
<div className="profile-menu-summary">
|
||||||
padding: '16px',
|
<div className="profile-menu-summary-head">
|
||||||
borderBottom: '1px solid #eee',
|
<h4>Profile Details</h4>
|
||||||
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>
|
|
||||||
{onEditProfile && (
|
{onEditProfile && (
|
||||||
<button
|
<button
|
||||||
|
className="profile-menu-edit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onEditProfile();
|
onEditProfile();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
background: '#0066cc',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '3px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#555', lineHeight: '1.6' }}>
|
<div className="profile-menu-details">
|
||||||
<p style={{ margin: '4px 0' }}><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
<p><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
||||||
<p style={{ margin: '4px 0' }}><strong>Email:</strong> {user.email}</p>
|
<p><strong>Email:</strong> {user.email}</p>
|
||||||
{user.phone && <p style={{ margin: '4px 0' }}><strong>Phone:</strong> {user.phone}</p>}
|
{user.phone && <p><strong>Phone:</strong> {user.phone}</p>}
|
||||||
{user.address && <p style={{ margin: '4px 0' }}><strong>Address:</strong> {user.address}</p>}
|
{user.address && <p><strong>Address:</strong> {user.address}</p>}
|
||||||
<p style={{ margin: '4px 0' }}><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
<p><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
style={{
|
className={`profile-menu-item ${user ? '' : 'first'}`}
|
||||||
...menuItemStyle,
|
|
||||||
borderRadius: '0',
|
|
||||||
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
|
|
||||||
}}
|
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
>
|
>
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px', borderTop: '1px solid #eee' }}
|
className="profile-menu-item last"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
Log Out
|
Log Out
|
||||||
@@ -198,6 +109,7 @@ interface ChangePasswordModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) => {
|
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) => {
|
||||||
|
const toast = useToast();
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
@@ -226,7 +138,7 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
|||||||
new_password: newPassword
|
new_password: newPassword
|
||||||
});
|
});
|
||||||
|
|
||||||
alert('Password changed successfully!');
|
toast.success('Password changed successfully.');
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setError(error.response?.data?.detail || 'Failed to change password');
|
setError(error.response?.data?.detail || 'Failed to change password');
|
||||||
@@ -285,33 +197,19 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}>
|
<div className="modal-button-row">
|
||||||
<button
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
backgroundColor: '#6c757d',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
backgroundColor: '#28a745',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Changing...' : 'Change Password'}
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 React, { useState, useEffect } from 'react';
|
||||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||||
import EmailTemplateManagement from './EmailTemplateManagement';
|
import EmailTemplateManagement from './EmailTemplateManagement';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../contexts/ConfirmContext';
|
||||||
|
import { formatLondonDate } from '../utils/timezone';
|
||||||
|
|
||||||
interface SuperAdminMenuProps {
|
interface SuperAdminMenuProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||||
|
const toast = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
|
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
|
||||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -26,7 +31,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
|||||||
setTiers(tierData);
|
setTiers(tierData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tiers:', error);
|
console.error('Failed to load tiers:', error);
|
||||||
alert('Failed to load membership tiers');
|
toast.error('Failed to load membership tiers.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -38,7 +43,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
|||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} 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);
|
setEditingTier(null);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} 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) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +72,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
|||||||
await membershipService.deleteTier(tierId);
|
await membershipService.deleteTier(tierId);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} 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;
|
onCancelEdit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TierManagement: React.FC<TierManagementProps> = ({
|
export const TierManagement: React.FC<TierManagementProps> = ({
|
||||||
tiers,
|
tiers,
|
||||||
loading,
|
loading,
|
||||||
showCreateForm,
|
|
||||||
editingTier,
|
|
||||||
onCreateTier,
|
|
||||||
onUpdateTier,
|
|
||||||
onDeleteTier,
|
onDeleteTier,
|
||||||
onShowCreateForm,
|
|
||||||
onHideCreateForm,
|
|
||||||
onEditTier,
|
onEditTier,
|
||||||
onCancelEdit
|
|
||||||
}) => {
|
}) => {
|
||||||
if (loading) {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
<div className="admin-page-header">
|
||||||
<h4 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h4>
|
<div>
|
||||||
<button
|
<h3>Membership Tiers</h3>
|
||||||
onClick={onShowCreateForm}
|
<p>Manage pricing, availability, and the copy members see when choosing a plan.</p>
|
||||||
className="btn btn-primary"
|
</div>
|
||||||
style={{ fontSize: '14px', padding: '8px 16px' }}
|
|
||||||
>
|
|
||||||
Create New Tier
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreateForm && (
|
<div className="admin-table-shell">
|
||||||
<TierForm
|
<div className="admin-table-wrap">
|
||||||
onSubmit={onCreateTier}
|
<table className="admin-table">
|
||||||
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">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
<th>Name</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Description</th>
|
<th>Description</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Annual Fee</th>
|
<th>Annual Fee</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Benefits</th>
|
<th>Benefits</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
<th>Status</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tiers.map(tier => (
|
{tiers.map(tier => (
|
||||||
<tr key={tier.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={tier.id}>
|
||||||
<td style={{ padding: '12px', fontWeight: 'bold' }}>{tier.name}</td>
|
<td>
|
||||||
<td style={{ padding: '12px', maxWidth: '200px' }}>
|
<strong>{tier.name}</strong>
|
||||||
{tier.description || 'No description'}
|
<span className="muted-line">Created {formatLondonDate(tier.created_at)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>£{tier.annual_fee.toFixed(2)}</td>
|
<td>{tier.description || 'No description'}</td>
|
||||||
<td style={{ padding: '12px', maxWidth: '250px' }}>
|
<td>£{tier.annual_fee.toFixed(2)}</td>
|
||||||
{tier.benefits || 'No benefits listed'}
|
<td className="admin-tier-benefits-cell">{tier.benefits || 'No benefits listed'}</td>
|
||||||
</td>
|
<td>
|
||||||
<td style={{ padding: '12px' }}>
|
|
||||||
<span className={`status-badge ${tier.is_active ? 'status-active' : 'status-expired'}`}>
|
<span className={`status-badge ${tier.is_active ? 'status-active' : 'status-expired'}`}>
|
||||||
{tier.is_active ? 'ACTIVE' : 'INACTIVE'}
|
{tier.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td>
|
||||||
|
<div className="table-button-row">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onEditTier(tier)}
|
onClick={() => onEditTier(tier)}
|
||||||
className="action-btn"
|
className="btn btn-secondary"
|
||||||
style={{ marginRight: '8px', color: 'white', backgroundColor: '#007bff', border: '1px solid #007bff' }}
|
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onDeleteTier(tier.id)}
|
onClick={() => onDeleteTier(tier.id)}
|
||||||
className="action-btn action-btn-danger"
|
className="btn btn-danger"
|
||||||
style={{ color: 'white', backgroundColor: '#dc3545', border: '1px solid #dc3545' }}
|
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{tiers.length === 0 && <p className="admin-empty admin-table-empty">No membership tiers found.</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -268,9 +255,10 @@ interface TierFormProps {
|
|||||||
onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
|
onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
title: string;
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: initialData?.name || '',
|
name: initialData?.name || '',
|
||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
@@ -288,18 +276,21 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
|||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
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 (
|
return (
|
||||||
<div style={{
|
<div className={panelClassName}>
|
||||||
background: '#f8f9fa',
|
<h4>{title}</h4>
|
||||||
padding: '20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
border: '1px solid #dee2e6'
|
|
||||||
}}>
|
|
||||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>{title}</h4>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
<div className={gridClassName}>
|
||||||
<div className="modal-form-group">
|
<div className="modal-form-group">
|
||||||
<label>Name *</label>
|
<label>Name *</label>
|
||||||
<input
|
<input
|
||||||
@@ -323,7 +314,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-form-group" style={{ marginBottom: '16px' }}>
|
<div className="modal-form-group">
|
||||||
<label>Description</label>
|
<label>Description</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -333,28 +324,19 @@ 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>Benefits</label>
|
<label>Benefits</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.benefits}
|
value={formData.benefits}
|
||||||
onChange={(e) => handleChange('benefits', e.target.value)}
|
onChange={(e) => handleChange('benefits', e.target.value)}
|
||||||
placeholder="List the benefits of this membership tier"
|
placeholder="List the benefits of this membership tier"
|
||||||
rows={3}
|
rows={3}
|
||||||
style={{
|
className="admin-inline-textarea"
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#333',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
resize: 'vertical'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div className="admin-inline-toggle-row">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.is_active}
|
checked={formData.is_active}
|
||||||
@@ -364,7 +346,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
<div className="modal-buttons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
|
|||||||
+1995
-943
File diff suppressed because it is too large
Load Diff
@@ -26,46 +26,68 @@ const ForgotPassword: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-shell">
|
||||||
<div className="auth-card">
|
<header className="auth-topbar">
|
||||||
<h2>Forgot Password</h2>
|
<div className="portal-brand">
|
||||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
<div className="portal-mark">S</div>
|
||||||
Enter your email address and we'll send you a link to reset your password.
|
<div className="portal-brand-text">
|
||||||
</p>
|
<h1>SASA Member Portal</h1>
|
||||||
|
<div className="portal-subtitle">Account recovery</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
<main className="auth-container">
|
||||||
{message && <div className="alert alert-success">{message}</div>}
|
<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}>
|
<section className="auth-card">
|
||||||
<div className="form-group">
|
<div className="auth-card-head">
|
||||||
<label htmlFor="email">Email Address</label>
|
<h2>Forgot Password</h2>
|
||||||
<input
|
<span>Email reset link</span>
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Enter your email address"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="auth-card-body">
|
||||||
type="submit"
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
className="btn btn-primary"
|
{message && <div className="alert alert-success">{message}</div>}
|
||||||
disabled={loading}
|
|
||||||
style={{ width: '100%', marginTop: '16px' }}
|
|
||||||
>
|
|
||||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="form-footer">
|
<form onSubmit={handleSubmit}>
|
||||||
<Link to="/login" style={{ color: '#0066cc', textDecoration: 'none' }}>
|
<div className="form-group">
|
||||||
Back to login
|
<label htmlFor="email">Email Address</label>
|
||||||
</Link>
|
<input
|
||||||
</div>
|
type="email"
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,84 +43,89 @@ const Login: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container" style={{ gap: '40px', padding: '20px' }}>
|
<div className="auth-shell">
|
||||||
<div className="welcome-section" style={{
|
<header className="auth-topbar">
|
||||||
flex: '1',
|
<div className="portal-brand">
|
||||||
maxWidth: '400px',
|
<div className="portal-mark">S</div>
|
||||||
textAlign: 'center',
|
<div className="portal-brand-text">
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
<h1>SASA Member Portal</h1>
|
||||||
padding: '30px',
|
<div className="portal-subtitle">Member access and admin control room</div>
|
||||||
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>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../contexts/ConfirmContext';
|
||||||
|
|
||||||
const MembershipTiers: React.FC = () => {
|
const MembershipTiers: React.FC = () => {
|
||||||
|
const toast = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -20,7 +24,7 @@ const MembershipTiers: React.FC = () => {
|
|||||||
setTiers(tierData);
|
setTiers(tierData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tiers:', error);
|
console.error('Failed to load tiers:', error);
|
||||||
alert('Failed to load membership tiers');
|
toast.error('Failed to load membership tiers.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -32,7 +36,7 @@ const MembershipTiers: React.FC = () => {
|
|||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} 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);
|
setEditingTier(null);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} 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) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +65,7 @@ const MembershipTiers: React.FC = () => {
|
|||||||
await membershipService.deleteTier(tierId);
|
await membershipService.deleteTier(tierId);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.response?.data?.detail || 'Failed to delete tier');
|
toast.error(error.response?.data?.detail || 'Failed to delete tier.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { authService, RegisterData } from '../services/membershipService';
|
import { authService, RegisterData } from '../services/membershipService';
|
||||||
|
|
||||||
const Register: React.FC = () => {
|
const Register: React.FC = () => {
|
||||||
@@ -67,142 +67,164 @@ const Register: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-shell">
|
||||||
<div className="auth-card">
|
<header className="auth-topbar">
|
||||||
<h2>Create Your Account</h2>
|
<div className="portal-brand">
|
||||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
<div className="portal-mark">S</div>
|
||||||
Join Swansea Airport Stakeholders Alliance
|
<div className="portal-brand-text">
|
||||||
</p>
|
<h1>SASA Member Portal</h1>
|
||||||
|
<div className="portal-subtitle">Membership registration and profile setup</div>
|
||||||
{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>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,72 +55,117 @@ const ResetPassword: React.FC = () => {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-shell">
|
||||||
<div className="auth-card">
|
<header className="auth-topbar">
|
||||||
<h2>Invalid Reset Link</h2>
|
<div className="portal-brand">
|
||||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
<div className="portal-mark">S</div>
|
||||||
This password reset link is invalid or has expired. Please request a new password reset.
|
<div className="portal-brand-text">
|
||||||
</p>
|
<h1>SASA Member Portal</h1>
|
||||||
<button
|
<div className="portal-subtitle">Account recovery</div>
|
||||||
onClick={() => navigate('/forgot-password')}
|
</div>
|
||||||
className="btn btn-primary"
|
</div>
|
||||||
style={{ width: '100%' }}
|
</header>
|
||||||
>
|
|
||||||
Request New Reset Link
|
<main className="auth-container">
|
||||||
</button>
|
<section className="auth-welcome-card">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-shell">
|
||||||
<div className="auth-card">
|
<header className="auth-topbar">
|
||||||
<h2>Reset Password</h2>
|
<div className="portal-brand">
|
||||||
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
|
<div className="portal-mark">S</div>
|
||||||
Enter your new password below. Make sure it's at least 8 characters long.
|
<div className="portal-brand-text">
|
||||||
</p>
|
<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>}
|
<main className="auth-container">
|
||||||
{message && <div className="alert alert-success">{message}</div>}
|
<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}>
|
<section className="auth-card">
|
||||||
<div className="form-group">
|
<div className="auth-card-head">
|
||||||
<label htmlFor="newPassword">New Password</label>
|
<h2>Reset Password</h2>
|
||||||
<input
|
<span>Secure update</span>
|
||||||
type="password"
|
|
||||||
id="newPassword"
|
|
||||||
name="newPassword"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Enter new password"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="auth-card-body">
|
||||||
<label htmlFor="confirmPassword">Confirm New Password</label>
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
<input
|
{message && <div className="alert alert-success">{message}</div>}
|
||||||
type="password"
|
|
||||||
id="confirmPassword"
|
|
||||||
name="confirmPassword"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<form onSubmit={handleSubmit}>
|
||||||
type="submit"
|
<div className="form-group">
|
||||||
className="btn btn-primary"
|
<label htmlFor="newPassword">New Password</label>
|
||||||
disabled={loading}
|
<input
|
||||||
style={{ width: '100%', marginTop: '16px' }}
|
type="password"
|
||||||
>
|
id="newPassword"
|
||||||
{loading ? 'Resetting...' : 'Reset Password'}
|
name="newPassword"
|
||||||
</button>
|
value={newPassword}
|
||||||
</form>
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 api from './api';
|
||||||
|
import { ensureUtcIso } from '../utils/timezone';
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -26,11 +27,62 @@ export interface User {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
address: string | null;
|
address: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
|
volunteer_level: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login: string | null;
|
last_login: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProfileQuestionInputType = 'text' | 'number' | 'boolean' | 'date' | 'select';
|
||||||
|
|
||||||
|
export interface ProfileQuestionOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileQuestion {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
help_text: string | null;
|
||||||
|
input_type: ProfileQuestionInputType;
|
||||||
|
placeholder: string | null;
|
||||||
|
options: ProfileQuestionOption[];
|
||||||
|
is_required: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
admin_only_edit: boolean;
|
||||||
|
display_order: number;
|
||||||
|
depends_on_question_id: number | null;
|
||||||
|
depends_on_value: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileQuestionForUser extends ProfileQuestion {
|
||||||
|
answer: string | number | boolean | null;
|
||||||
|
can_edit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileQuestionUpsertData {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
help_text?: string | null;
|
||||||
|
input_type: ProfileQuestionInputType;
|
||||||
|
placeholder?: string | null;
|
||||||
|
options?: ProfileQuestionOption[] | null;
|
||||||
|
is_required?: boolean;
|
||||||
|
is_active?: boolean;
|
||||||
|
admin_only_edit?: boolean;
|
||||||
|
display_order?: number;
|
||||||
|
depends_on_question_id?: number | null;
|
||||||
|
depends_on_value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileAnswerInput {
|
||||||
|
question_id: number;
|
||||||
|
value: string | number | boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MembershipTier {
|
export interface MembershipTier {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -169,6 +221,127 @@ export interface EventRSVPData {
|
|||||||
notes?: string;
|
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 = {
|
export const authService = {
|
||||||
async register(data: RegisterData) {
|
async register(data: RegisterData) {
|
||||||
const response = await api.post('/auth/register', data);
|
const response = await api.post('/auth/register', data);
|
||||||
@@ -230,6 +403,51 @@ export const userService = {
|
|||||||
const response = await api.delete(`/users/${userId}`);
|
const response = await api.delete(`/users/${userId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMyProfileQuestions(): Promise<ProfileQuestionForUser[]> {
|
||||||
|
const response = await api.get('/users/me/profile-questions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateMyProfileAnswers(answers: ProfileAnswerInput[]): Promise<{ message: string }> {
|
||||||
|
const response = await api.put('/users/me/profile-answers', { answers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAdminProfileQuestions(includeInactive: boolean = true): Promise<ProfileQuestion[]> {
|
||||||
|
const response = await api.get(`/users/admin/profile-questions?include_inactive=${includeInactive}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAdminProfileQuestion(data: ProfileQuestionUpsertData): Promise<ProfileQuestion> {
|
||||||
|
const response = await api.post('/users/admin/profile-questions', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAdminProfileQuestion(questionId: number, data: Partial<ProfileQuestionUpsertData>): Promise<ProfileQuestion> {
|
||||||
|
const response = await api.put(`/users/admin/profile-questions/${questionId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deactivateAdminProfileQuestion(questionId: number): Promise<{ message: string }> {
|
||||||
|
const response = await api.delete(`/users/admin/profile-questions/${questionId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserProfileAnswers(userId: number): Promise<ProfileQuestionForUser[]> {
|
||||||
|
const response = await api.get(`/users/admin/users/${userId}/profile-answers`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUserProfileAnswers(userId: number, answers: ProfileAnswerInput[]): Promise<{ message: string }> {
|
||||||
|
const response = await api.put(`/users/admin/users/${userId}/profile-answers`, { answers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendUserPasswordReset(userId: number): Promise<{ message: string }> {
|
||||||
|
const response = await api.post(`/users/${userId}/send-password-reset`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const membershipService = {
|
export const membershipService = {
|
||||||
@@ -313,12 +531,18 @@ export const eventService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createEvent(data: EventCreateData): Promise<Event> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateEvent(eventId: number, data: EventUpdateData): Promise<Event> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -342,3 +566,80 @@ export const eventService = {
|
|||||||
return response.data;
|
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 { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
@@ -11,9 +19,7 @@ export default defineConfig({
|
|||||||
watch: {
|
watch: {
|
||||||
usePolling: true
|
usePolling: true
|
||||||
},
|
},
|
||||||
hmr: {
|
hmr: hmrConfig,
|
||||||
clientPort: 8050
|
|
||||||
},
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://backend:8000',
|
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