main #1

Open
nathanb wants to merge 9 commits from nathanb/sasa-membership:main into main
78 changed files with 17893 additions and 2540 deletions
+23
View File
@@ -0,0 +1,23 @@
# An .aiignore file follows the same syntax as a .gitignore file.
# .gitignore documentation: https://git-scm.com/docs/gitignore
# you can ignore files
.DS_Store
*.log
*.tmp
# or folders
dist/
build/
out/
.idea/
/.aiignore
.aiignore
/.env
/.env.*
.env
.env.*
**/.env
**/.env.*
+8
View File
@@ -6,6 +6,7 @@ __pycache__/
.Python .Python
env/ env/
venv/ venv/
.venv/
ENV/ ENV/
build/ build/
develop-eggs/ develop-eggs/
@@ -23,6 +24,13 @@ wheels/
.installed.cfg .installed.cfg
*.egg *.egg
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables # Environment variables
.env .env
.env.local .env.local
+463
View File
@@ -0,0 +1,463 @@
# ESP RFID Integration Contract
The ESP32 reader is expected to host its own local setup/dashboard UI. The reader registers itself with the SASA server, waits for admin approval, receives an API key once, then uses that key for heartbeat, time sync, taps, dashboard login checks, and queued card-write jobs.
All timestamps are UTC ISO-8601 unless otherwise noted.
## 1. Reader Setup and Approval
### Register from the ESP dashboard
Unauthenticated endpoint. Use this when the reader first boots or when it is reset/reconfigured.
```http
POST /api/v1/esp/device/register
Content-Type: application/json
```
Request:
```json
{
"device_id": "front-desk-01",
"name": "Front Desk Reader",
"location": "Reception",
"reader_type": "checkin_checkout",
"can_write_cards": true,
"firmware_version": "esp32-rfid-0.1.0",
"notes": "PN532 over I2C"
}
```
Response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "pending",
"registration_token": "one-time-registration-token",
"message": "Registration received. Approve this reader in the admin panel.",
"poll_interval_seconds": 5
}
```
Firmware must store:
- `device_id`
- `registration_token`
### Admin approval
In the web portal:
- Dashboard -> Admin -> ESP RFID -> Setup
- Approve or reject pending readers.
When approved, the server does not show the key in the admin panel. The reader gets it through the provisioning poll below.
### Poll provisioning status
Use this while waiting for approval.
```http
GET /api/v1/esp/device/provisioning-status
X-ESP-Device-ID: front-desk-01
X-ESP-Registration-Token: one-time-registration-token
```
Pending response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "pending",
"message": "Waiting for admin approval.",
"api_key": null,
"poll_interval_seconds": 5
}
```
Approved response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "provisioned",
"message": "Reader approved. Store this API key; it will not be returned again.",
"api_key": "generated-api-key",
"poll_interval_seconds": 5
}
```
Firmware must store `api_key` permanently. The server only returns it once. If it is lost, either rotate the key in admin and manually enter it on the ESP, or re-register the reader.
Rejected response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "rejected",
"message": "Reader registration rejected.",
"api_key": null,
"poll_interval_seconds": 5
}
```
## 2. Authenticated Reader Requests
After provisioning, every reader endpoint uses:
```http
X-ESP-Device-ID: front-desk-01
X-ESP-API-Key: generated-api-key
Content-Type: application/json
```
Invalid, inactive, unapproved, or rejected readers receive `401`.
## 3. Time Sync
The backend returns the server clock in UTC/Zulu. Firmware should treat this as the source of truth and display it locally as needed.
## 3. Local ESP Dashboard
The firmware in `ESP/main.cpp` serves the local dashboard on port `80`.
Access options:
- If WiFi connects, open `http://<reader-lan-ip>/`.
- If WiFi does not connect, the reader starts a setup AP named `RFID-Setup-XXXXXX`; connect to it and open `http://192.168.4.1/`.
Default local dashboard credentials are configured in `ESP/secrets.h`:
- Username: `admin`
- Password: `admin`
The dashboard shows:
- WiFi status and RSSI
- Provisioning status
- Current operating mode
- Server-synced clock
- Last heartbeat result
- Current write-job state
- Last card UID/action/result
The local UI also exposes controls for:
- Saving server/WiFi/reader configuration
- Registering or re-registering the reader
- Manually syncing time
- Manually polling for queued write jobs
- Clearing provisioning
- Rebooting
Local dashboard JSON endpoints:
- `GET /api/status`
- `POST /api/config`
- `POST /api/register`
- `POST /api/sync-time`
- `POST /api/poll-job`
- `POST /api/clear-provisioning`
- `POST /api/cancel-local-job`
- `POST /api/reboot`
These are local ESP endpoints, not SASA backend endpoints.
## 4. Heartbeat
The reader sends a heartbeat after provisioning so the server knows it is alive and so the ESP can show heartbeat status on its dashboard.
```http
POST /api/v1/esp/device/heartbeat
X-ESP-Device-ID: front-desk-01
X-ESP-API-Key: generated-api-key
Content-Type: application/json
```
Request:
```json
{
"mode": "idle",
"message": "Heartbeat OK",
"wifi_rssi": -54,
"free_heap": 184320,
"firmware_version": "esp32-rfid-0.2.0",
"active_write_job_id": null
}
```
Response:
```json
{
"ok": true,
"server_time_utc": "2026-05-05T10:15:30.123456",
"unix_ms": 1777976130123,
"heartbeat_interval_seconds": 10,
"time_poll_interval_seconds": 3,
"write_job_poll_interval_seconds": 3
}
```
Firmware modes currently used:
- `setup`
- `waiting approval`
- `idle`
- `syncing time`
- `heartbeat`
- `checking jobs`
- `ready to write`
- `writing card`
- `reporting tap`
- `error`
The backend updates `last_seen_at` when heartbeat succeeds.
## 5. Time Sync
The reader should poll every 3 seconds.
```http
GET /api/v1/esp/device/time
```
Response:
```json
{
"server_time_utc": "2026-05-05T10:15:30.123456",
"unix_ms": 1777976130123,
"poll_interval_seconds": 3
}
```
Use `unix_ms` to correct the ESP local clock. Do not convert the value to a local timezone before applying it on the device.
## 6. Check-In and Check-Out Taps
```http
POST /api/v1/esp/device/taps
```
Request:
```json
{
"card_uid": "04A1B2C3D4",
"tapped_at": "2026-05-05T10:15:30.123456",
"reader_type": "checkin_checkout"
}
```
`tapped_at` is optional. If omitted, server time is used. If provided, it is normalized to UTC before persistence.
Accepted check-in:
```json
{
"accepted": true,
"action": "check_in",
"message": "Checked in",
"server_time_utc": "2026-05-05T10:15:30.123456",
"tap_id": 123,
"session_id": 45,
"user_id": 7,
"user_name": "Jane Smith",
"checked_in_at": "2026-05-05T10:15:30.123456",
"checked_out_at": null,
"duration_seconds": null
}
```
Accepted check-out:
```json
{
"accepted": true,
"action": "check_out",
"message": "Checked out",
"server_time_utc": "2026-05-05T12:20:30.123456",
"tap_id": 124,
"session_id": 45,
"user_id": 7,
"user_name": "Jane Smith",
"checked_in_at": "2026-05-05T10:15:30.123456",
"checked_out_at": "2026-05-05T12:20:30.123456",
"duration_seconds": 7500
}
```
Denied:
```json
{
"accepted": false,
"action": "denied",
"message": "Unknown RFID card",
"server_time_utc": "2026-05-05T10:15:30.123456",
"tap_id": 125,
"session_id": null,
"user_id": null,
"user_name": null
}
```
The server logs denied taps so unknown cards can still be audited.
The stored tap, check-in, check-out, approval, heartbeat, and write-job timestamps are all UTC. The frontend renders those values in Europe/London, but the backend and ESP contract stay on UTC/Zulu.
## 7. Queued RFID Card Writes
Admins queue card writes in:
- Dashboard -> Admin -> ESP RFID -> Setup -> Queue Card Write
They select:
- User
- Writing reader
- Card label
The reader does not need an inbound network port. It polls for jobs.
### Poll next write job
Only readers with `can_write_cards=true` may use this endpoint.
```http
GET /api/v1/esp/device/write-jobs/next
```
No job response:
```json
null
```
Job response:
```json
{
"id": 20,
"reader_id": 3,
"user_id": 7,
"card_id": null,
"label": "Jane main card",
"status": "claimed",
"requested_by_user_id": 1,
"card_uid": null,
"write_payload": "{\"job_id\":20,\"user_id\":7,\"user_name\":\"Jane Smith\",\"label\":\"Jane main card\",\"issued_at\":\"2026-05-05T10:15:30.123456\"}",
"claimed_at": "2026-05-05T10:15:30.123456",
"completed_at": null,
"error_message": null,
"created_at": "2026-05-05T10:14:00.000000",
"updated_at": "2026-05-05T10:15:30.123456"
}
```
Firmware behavior:
1. Poll for a job.
2. If a job is returned, enter `ready to write` mode and show the target user/label on the ESP dashboard.
3. On the next card tap, write `write_payload` to the card if supported by the chosen RFID card type.
4. Report success/failure.
The included ESP firmware writes MIFARE Classic 1K/4K cards using the default key `FF FF FF FF FF FF`, starting at block 4 and skipping trailer blocks. Unsupported cards are reported as failed write jobs.
### Complete write job
```http
POST /api/v1/esp/device/write-jobs/20/complete
```
Success request:
```json
{
"success": true,
"card_uid": "04A1B2C3D4"
}
```
Failure request:
```json
{
"success": false,
"error_message": "Card write failed verification"
}
```
On success, the server creates or updates the RFID card record and assigns it to the selected user with the queued label.
## 8. ESP Local Dashboard Login
Use this when someone logs into the ESP-hosted dashboard.
```http
POST /api/v1/esp/device/dashboard-login
```
Request:
```json
{
"email": "admin@example.com",
"password": "password"
}
```
Response:
```json
{
"valid": true,
"user_id": 1,
"role": "admin",
"user_name": "Admin User"
}
```
Only `admin` and `super_admin` users return `valid: true`.
## 9. Admin API Summary
These endpoints use the normal portal JWT.
- `GET /api/v1/esp/admin/readers`
- `POST /api/v1/esp/admin/readers` manual reader/key creation fallback
- `PUT /api/v1/esp/admin/readers/{reader_id}`
- `POST /api/v1/esp/admin/readers/{reader_id}/approve`
- `POST /api/v1/esp/admin/readers/{reader_id}/reject`
- `DELETE /api/v1/esp/admin/readers/{reader_id}`
- `POST /api/v1/esp/device/heartbeat`
- `GET /api/v1/esp/admin/cards`
- `POST /api/v1/esp/admin/cards`
- `PUT /api/v1/esp/admin/cards/{card_id}`
- `GET /api/v1/esp/admin/write-jobs?limit=100`
- `POST /api/v1/esp/admin/write-jobs`
- `POST /api/v1/esp/admin/write-jobs/{job_id}/cancel`
- `GET /api/v1/esp/admin/taps?limit=100`
- `GET /api/v1/esp/admin/attendance?open_only=false&limit=100`
- `POST /api/v1/esp/admin/attendance/close-stale`
Stale close request:
```json
{
"checkout_hour": 17
}
```
The backend also runs stale closing automatically on startup and hourly. It closes sessions checked in before today's midnight, sets checkout to 17:00 on the check-in date, and marks `checkout_source` as `system`.
## 10. Time Contract Summary
- API responses use UTC/Zulu datetimes.
- ESP readers sync against `/api/v1/esp/device/time`.
- Reader-supplied timestamps are normalized to UTC before storage.
- The browser frontend displays these values in Europe/London for operators and members.
+45 -13
View File
@@ -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
@@ -152,4 +184,4 @@ Each tier will have associated annual fees and benefits.
- Payment processing success rate - Payment processing success rate
- User engagement with portal - User engagement with portal
- Administrative efficiency improvements - Administrative efficiency improvements
- System uptime and performance - System uptime and performance
+119 -76
View File
@@ -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
View File
@@ -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
+121 -19
View File
@@ -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
View File
@@ -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 |
+9
View File
@@ -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
+4 -3
View File
@@ -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:
+9 -7
View File
@@ -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')
@@ -0,0 +1,130 @@
"""Add ESP RFID attendance tables
Revision ID: 8d2b0c4a1f7e
Revises: 2e8a0f9d4b31
Create Date: 2026-05-05 11:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "8d2b0c4a1f7e"
down_revision: Union[str, None] = "2e8a0f9d4b31"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"esp_readers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("device_id", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("location", sa.String(length=255), nullable=True),
sa.Column("reader_type", sa.Enum("checkin_checkout", name="espreadertype"), nullable=False),
sa.Column("api_key_hash", sa.String(length=255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_esp_readers_device_id"), "esp_readers", ["device_id"], unique=True)
op.create_index(op.f("ix_esp_readers_id"), "esp_readers", ["id"], unique=False)
op.create_table(
"rfid_cards",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uid", sa.String(length=100), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("label", sa.String(length=255), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rfid_cards_id"), "rfid_cards", ["id"], unique=False)
op.create_index(op.f("ix_rfid_cards_uid"), "rfid_cards", ["uid"], unique=True)
op.create_index(op.f("ix_rfid_cards_user_id"), "rfid_cards", ["user_id"], unique=False)
op.create_table(
"rfid_taps",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reader_id", sa.Integer(), nullable=False),
sa.Column("card_id", sa.Integer(), nullable=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("card_uid", sa.String(length=100), nullable=False),
sa.Column("action", sa.Enum("check_in", "check_out", "denied", "unknown", name="esptapaction"), nullable=False),
sa.Column("accepted", sa.Boolean(), nullable=False),
sa.Column("message", sa.String(length=255), nullable=True),
sa.Column("raw_payload", sa.Text(), nullable=True),
sa.Column("tapped_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]),
sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rfid_taps_card_id"), "rfid_taps", ["card_id"], unique=False)
op.create_index(op.f("ix_rfid_taps_card_uid"), "rfid_taps", ["card_uid"], unique=False)
op.create_index(op.f("ix_rfid_taps_id"), "rfid_taps", ["id"], unique=False)
op.create_index(op.f("ix_rfid_taps_reader_id"), "rfid_taps", ["reader_id"], unique=False)
op.create_index(op.f("ix_rfid_taps_tapped_at"), "rfid_taps", ["tapped_at"], unique=False)
op.create_index(op.f("ix_rfid_taps_user_id"), "rfid_taps", ["user_id"], unique=False)
op.create_table(
"attendance_sessions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("reader_id", sa.Integer(), nullable=False),
sa.Column("check_in_tap_id", sa.Integer(), nullable=False),
sa.Column("check_out_tap_id", sa.Integer(), nullable=True),
sa.Column("checked_in_at", sa.DateTime(), nullable=False),
sa.Column("checked_out_at", sa.DateTime(), nullable=True),
sa.Column("checkout_source", sa.Enum("user", "system", name="attendancecheckoutsource"), nullable=True),
sa.Column("system_flag_reason", sa.String(length=255), nullable=True),
sa.Column("duration_seconds", sa.Integer(), nullable=True),
sa.Column("is_open", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["check_in_tap_id"], ["rfid_taps.id"]),
sa.ForeignKeyConstraint(["check_out_tap_id"], ["rfid_taps.id"]),
sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_attendance_sessions_checked_in_at"), "attendance_sessions", ["checked_in_at"], unique=False)
op.create_index(op.f("ix_attendance_sessions_checked_out_at"), "attendance_sessions", ["checked_out_at"], unique=False)
op.create_index(op.f("ix_attendance_sessions_id"), "attendance_sessions", ["id"], unique=False)
op.create_index(op.f("ix_attendance_sessions_is_open"), "attendance_sessions", ["is_open"], unique=False)
op.create_index(op.f("ix_attendance_sessions_reader_id"), "attendance_sessions", ["reader_id"], unique=False)
op.create_index(op.f("ix_attendance_sessions_user_id"), "attendance_sessions", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_attendance_sessions_user_id"), table_name="attendance_sessions")
op.drop_index(op.f("ix_attendance_sessions_reader_id"), table_name="attendance_sessions")
op.drop_index(op.f("ix_attendance_sessions_is_open"), table_name="attendance_sessions")
op.drop_index(op.f("ix_attendance_sessions_id"), table_name="attendance_sessions")
op.drop_index(op.f("ix_attendance_sessions_checked_out_at"), table_name="attendance_sessions")
op.drop_index(op.f("ix_attendance_sessions_checked_in_at"), table_name="attendance_sessions")
op.drop_table("attendance_sessions")
op.drop_index(op.f("ix_rfid_taps_user_id"), table_name="rfid_taps")
op.drop_index(op.f("ix_rfid_taps_tapped_at"), table_name="rfid_taps")
op.drop_index(op.f("ix_rfid_taps_reader_id"), table_name="rfid_taps")
op.drop_index(op.f("ix_rfid_taps_id"), table_name="rfid_taps")
op.drop_index(op.f("ix_rfid_taps_card_uid"), table_name="rfid_taps")
op.drop_index(op.f("ix_rfid_taps_card_id"), table_name="rfid_taps")
op.drop_table("rfid_taps")
op.drop_index(op.f("ix_rfid_cards_user_id"), table_name="rfid_cards")
op.drop_index(op.f("ix_rfid_cards_uid"), table_name="rfid_cards")
op.drop_index(op.f("ix_rfid_cards_id"), table_name="rfid_cards")
op.drop_table("rfid_cards")
op.drop_index(op.f("ix_esp_readers_id"), table_name="esp_readers")
op.drop_index(op.f("ix_esp_readers_device_id"), table_name="esp_readers")
op.drop_table("esp_readers")
@@ -0,0 +1,81 @@
"""Add ESP provisioning and RFID write jobs
Revision ID: c4f1d2a9b8e6
Revises: 8d2b0c4a1f7e
Create Date: 2026-05-05 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c4f1d2a9b8e6"
down_revision: Union[str, None] = "8d2b0c4a1f7e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"esp_readers",
sa.Column(
"provisioning_status",
sa.Enum("pending", "approved", "provisioned", "rejected", name="espreaderprovisioningstatus"),
nullable=False,
server_default="provisioned",
),
)
op.add_column("esp_readers", sa.Column("registration_token_hash", sa.String(length=255), nullable=True))
op.add_column("esp_readers", sa.Column("can_write_cards", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("esp_readers", sa.Column("firmware_version", sa.String(length=100), nullable=True))
op.add_column("esp_readers", sa.Column("approved_at", sa.DateTime(), nullable=True))
op.add_column("esp_readers", sa.Column("provisioned_at", sa.DateTime(), nullable=True))
op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=True)
op.create_table(
"rfid_card_write_jobs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reader_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("card_id", sa.Integer(), nullable=True),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("status", sa.Enum("pending", "claimed", "completed", "failed", "cancelled", name="rfidwritejobstatus"), nullable=False),
sa.Column("requested_by_user_id", sa.Integer(), nullable=False),
sa.Column("card_uid", sa.String(length=100), nullable=True),
sa.Column("write_payload", sa.Text(), nullable=True),
sa.Column("claimed_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("error_message", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]),
sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]),
sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rfid_card_write_jobs_card_id"), "rfid_card_write_jobs", ["card_id"], unique=False)
op.create_index(op.f("ix_rfid_card_write_jobs_card_uid"), "rfid_card_write_jobs", ["card_uid"], unique=False)
op.create_index(op.f("ix_rfid_card_write_jobs_id"), "rfid_card_write_jobs", ["id"], unique=False)
op.create_index(op.f("ix_rfid_card_write_jobs_reader_id"), "rfid_card_write_jobs", ["reader_id"], unique=False)
op.create_index(op.f("ix_rfid_card_write_jobs_status"), "rfid_card_write_jobs", ["status"], unique=False)
op.create_index(op.f("ix_rfid_card_write_jobs_user_id"), "rfid_card_write_jobs", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_rfid_card_write_jobs_user_id"), table_name="rfid_card_write_jobs")
op.drop_index(op.f("ix_rfid_card_write_jobs_status"), table_name="rfid_card_write_jobs")
op.drop_index(op.f("ix_rfid_card_write_jobs_reader_id"), table_name="rfid_card_write_jobs")
op.drop_index(op.f("ix_rfid_card_write_jobs_id"), table_name="rfid_card_write_jobs")
op.drop_index(op.f("ix_rfid_card_write_jobs_card_uid"), table_name="rfid_card_write_jobs")
op.drop_index(op.f("ix_rfid_card_write_jobs_card_id"), table_name="rfid_card_write_jobs")
op.drop_table("rfid_card_write_jobs")
op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=False)
op.drop_column("esp_readers", "provisioned_at")
op.drop_column("esp_readers", "approved_at")
op.drop_column("esp_readers", "firmware_version")
op.drop_column("esp_readers", "can_write_cards")
op.drop_column("esp_readers", "registration_token_hash")
op.drop_column("esp_readers", "provisioning_status")
@@ -0,0 +1,25 @@
"""Add pending ESP API key delivery
Revision ID: e7a9c2b1d4f0
Revises: c4f1d2a9b8e6
Create Date: 2026-05-05 13:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "e7a9c2b1d4f0"
down_revision: Union[str, None] = "c4f1d2a9b8e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("esp_readers", sa.Column("pending_api_key", sa.String(length=255), nullable=True))
def downgrade() -> None:
op.drop_column("esp_readers", "pending_api_key")
+2 -1
View File
@@ -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"])
+8 -7
View File
@@ -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()
+3 -2
View File
@@ -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
} }
+860
View File
@@ -0,0 +1,860 @@
import json
import secrets
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from ...api.dependencies import get_admin_user
from ...core.database import get_db
from ...core.datetime import to_utc_naive, to_zulu_iso, unix_ms_utc, utc_now
from ...core.security import (
get_machine_token_hash,
is_machine_token_hash,
verify_machine_token,
verify_password,
)
from ...models.models import (
AttendanceCheckoutSource,
AttendanceSession,
EspReader,
EspReaderProvisioningStatus,
EspTapAction,
RfidCard,
RfidCardWriteJob,
RfidTap,
RfidWriteJobStatus,
User,
UserRole,
)
from ...schemas import (
AttendanceSessionResponse,
EspDashboardLoginResponse,
EspHeartbeatRequest,
EspHeartbeatResponse,
EspReaderCreate,
EspReaderCreateResponse,
EspReaderProvisioningResponse,
EspReaderRegistrationRequest,
EspReaderRegistrationResponse,
EspReaderResponse,
EspReaderUpdate,
EspTimeResponse,
LoginRequest,
MessageResponse,
RfidCardCreate,
RfidCardResponse,
RfidCardUpdate,
RfidTapAdminResponse,
RfidTapRequest,
RfidTapResponse,
RfidWriteJobCompleteRequest,
RfidWriteJobCreate,
RfidWriteJobResponse,
StaleSessionCloseRequest,
StaleSessionCloseResponse,
)
from ...services.attendance_service import close_stale_attendance_sessions, duration_seconds
router = APIRouter()
READER_LAST_SEEN_WRITE_INTERVAL = timedelta(seconds=30)
def _normalize_card_uid(uid: str) -> str:
return uid.strip().upper().replace(" ", "")
def _new_api_key() -> str:
return secrets.token_urlsafe(32)
def _new_registration_token() -> str:
return secrets.token_urlsafe(24)
def _provisioning_status_value(value: object) -> str:
return getattr(value, "value", value)
def _compact_tap_response(tap: RfidTap) -> JSONResponse:
return JSONResponse(
content={
"ok": tap.accepted,
"a": getattr(tap.action, "value", tap.action),
"m": tap.message or "",
}
)
async def get_current_reader(
x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"),
x_esp_api_key: str = Header(..., alias="X-ESP-API-Key"),
db: Session = Depends(get_db),
) -> EspReader:
reader = db.query(EspReader).filter(EspReader.device_id == x_esp_device_id).first()
provisioning_status = _provisioning_status_value(reader.provisioning_status) if reader else None
credentials_valid = bool(
reader
and reader.is_active
and reader.api_key_hash
and provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]
and verify_machine_token(x_esp_api_key, reader.api_key_hash)
)
if (
not credentials_valid
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid ESP reader credentials",
)
now = utc_now()
should_persist_reader = False
if not is_machine_token_hash(reader.api_key_hash):
reader.api_key_hash = get_machine_token_hash(x_esp_api_key)
should_persist_reader = True
if (
reader.last_seen_at is None
or now - reader.last_seen_at >= READER_LAST_SEEN_WRITE_INTERVAL
):
reader.last_seen_at = now
should_persist_reader = True
if reader.pending_api_key:
reader.pending_api_key = None
should_persist_reader = True
if should_persist_reader:
db.commit()
return reader
def _get_reader_by_registration_token(
device_id: str,
registration_token: str,
db: Session,
) -> EspReader:
reader = db.query(EspReader).filter(EspReader.device_id == device_id).first()
credentials_valid = bool(
reader
and reader.registration_token_hash
and verify_machine_token(registration_token, reader.registration_token_hash)
)
if not credentials_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid reader registration token",
)
if not is_machine_token_hash(reader.registration_token_hash):
reader.registration_token_hash = get_machine_token_hash(registration_token)
db.commit()
return reader
@router.post("/device/register", response_model=EspReaderRegistrationResponse, status_code=status.HTTP_202_ACCEPTED)
async def register_reader(
registration: EspReaderRegistrationRequest,
db: Session = Depends(get_db),
):
existing = db.query(EspReader).filter(EspReader.device_id == registration.device_id).first()
existing_status = _provisioning_status_value(existing.provisioning_status) if existing else None
allow_recovery = bool(
existing
and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]
and existing.last_seen_at is None
)
if existing and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] and not allow_recovery:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Reader is already approved. Use its existing API key or rotate it from admin.",
)
registration_token = _new_registration_token()
now = utc_now()
if existing:
existing.name = registration.name
existing.location = registration.location
existing.reader_type = registration.reader_type
existing.can_write_cards = registration.can_write_cards
existing.firmware_version = registration.firmware_version
existing.notes = registration.notes
existing.registration_token_hash = get_machine_token_hash(registration_token)
existing.provisioning_status = (
EspReaderProvisioningStatus.APPROVED
if allow_recovery
else EspReaderProvisioningStatus.PENDING
)
existing.is_active = True
if not allow_recovery:
existing.pending_api_key = None
existing.updated_at = now
reader = existing
else:
reader = EspReader(
device_id=registration.device_id,
name=registration.name,
location=registration.location,
reader_type=registration.reader_type,
can_write_cards=registration.can_write_cards,
firmware_version=registration.firmware_version,
notes=registration.notes,
registration_token_hash=get_machine_token_hash(registration_token),
provisioning_status=EspReaderProvisioningStatus.PENDING,
is_active=True,
)
db.add(reader)
db.commit()
return {
"device_id": reader.device_id,
"provisioning_status": reader.provisioning_status,
"registration_token": registration_token,
"message": (
"Reader recovery accepted. Poll provisioning to receive the API key again."
if allow_recovery
else "Registration received. Approve this reader in the admin panel."
),
"poll_interval_seconds": 5,
}
@router.get("/device/provisioning-status", response_model=EspReaderProvisioningResponse)
async def get_provisioning_status(
x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"),
x_esp_registration_token: str = Header(..., alias="X-ESP-Registration-Token"),
db: Session = Depends(get_db),
):
reader = _get_reader_by_registration_token(x_esp_device_id, x_esp_registration_token, db)
provisioning_status = _provisioning_status_value(reader.provisioning_status)
if provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]:
api_key = reader.pending_api_key or _new_api_key()
now = utc_now()
if not reader.pending_api_key:
reader.api_key_hash = get_machine_token_hash(api_key)
reader.pending_api_key = api_key
reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED
reader.provisioned_at = now
reader.updated_at = now
db.commit()
payload = {
"device_id": reader.device_id,
"provisioning_status": EspReaderProvisioningStatus.PROVISIONED.value,
"message": "Reader approved. Store this API key; it will not be returned again.",
"api_key": api_key,
"apiKey": api_key,
"poll_interval_seconds": 5,
}
print(
f"[ESP] provisioning-status device={reader.device_id} status=provisioned has_api_key={bool(api_key)}"
)
return JSONResponse(content=payload)
messages = {
EspReaderProvisioningStatus.PENDING.value: "Waiting for admin approval.",
EspReaderProvisioningStatus.APPROVED.value: "Reader approved. API key is already available or will be generated shortly.",
EspReaderProvisioningStatus.PROVISIONED.value: "Reader already provisioned. Use the stored API key.",
EspReaderProvisioningStatus.REJECTED.value: "Reader registration rejected.",
}
payload = {
"device_id": reader.device_id,
"provisioning_status": provisioning_status,
"message": messages.get(provisioning_status, "Waiting for admin approval."),
"api_key": None,
"apiKey": None,
"poll_interval_seconds": 5,
}
print(
f"[ESP] provisioning-status device={reader.device_id} status={provisioning_status} has_api_key=False"
)
return JSONResponse(content=payload)
@router.get("/device/time", response_model=EspTimeResponse)
async def get_device_time(
reader: EspReader = Depends(get_current_reader),
):
now = utc_now()
return {
"server_time_utc": now,
"unix_ms": unix_ms_utc(now),
"poll_interval_seconds": 3,
}
@router.post("/device/heartbeat", response_model=EspHeartbeatResponse)
async def record_heartbeat(
heartbeat: EspHeartbeatRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
now = utc_now()
if heartbeat.firmware_version and heartbeat.firmware_version != reader.firmware_version:
reader.firmware_version = heartbeat.firmware_version
reader.last_seen_at = now
reader.updated_at = now
db.commit()
return {
"ok": True,
"server_time_utc": now,
"unix_ms": unix_ms_utc(now),
"heartbeat_interval_seconds": 10,
"time_poll_interval_seconds": 3,
"write_job_poll_interval_seconds": 3,
}
@router.post("/device/taps", response_model=RfidTapResponse)
async def record_tap(
tap_request: RfidTapRequest,
x_esp_compact_response: str | None = Header(None, alias="X-ESP-Compact-Response"),
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
compact_response = x_esp_compact_response == "1"
now = utc_now()
tapped_at = to_utc_naive(tap_request.tapped_at) or now
card_uid = _normalize_card_uid(tap_request.card_uid)
card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first()
tap = RfidTap(
reader_id=reader.id,
card_id=card.id if card else None,
user_id=card.user_id if card and card.is_active else None,
card_uid=card_uid,
action=EspTapAction.UNKNOWN,
accepted=False,
raw_payload=json.dumps(tap_request.model_dump(mode="json")),
tapped_at=tapped_at,
)
db.add(tap)
db.flush()
if not card:
tap.action = EspTapAction.DENIED
tap.message = "Unknown RFID card"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
}
if not card.is_active or not card.user_id:
tap.action = EspTapAction.DENIED
tap.message = "RFID card is inactive or unassigned"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
}
user = db.query(User).filter(User.id == card.user_id).first()
if not user or not user.is_active:
tap.action = EspTapAction.DENIED
tap.message = "User is inactive"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"user_id": card.user_id,
}
open_session = (
db.query(AttendanceSession)
.filter(
AttendanceSession.user_id == user.id,
AttendanceSession.is_open == True,
)
.order_by(AttendanceSession.checked_in_at.desc())
.first()
)
user_name = f"{user.first_name} {user.last_name}"
if open_session:
tap.action = EspTapAction.CHECK_OUT
tap.accepted = True
tap.message = "Checked out"
open_session.check_out_tap_id = tap.id
open_session.checked_out_at = tapped_at
open_session.checkout_source = AttendanceCheckoutSource.USER
open_session.duration_seconds = duration_seconds(open_session.checked_in_at, tapped_at)
open_session.is_open = False
open_session.updated_at = now
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": True,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"session_id": open_session.id,
"user_id": user.id,
"user_name": user_name,
"checked_in_at": open_session.checked_in_at,
"checked_out_at": open_session.checked_out_at,
"duration_seconds": open_session.duration_seconds,
}
tap.action = EspTapAction.CHECK_IN
tap.accepted = True
tap.message = "Checked in"
session = AttendanceSession(
user_id=user.id,
reader_id=reader.id,
check_in_tap_id=tap.id,
checked_in_at=tapped_at,
is_open=True,
)
db.add(session)
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": True,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"session_id": session.id,
"user_id": user.id,
"user_name": user_name,
"checked_in_at": session.checked_in_at,
}
@router.post("/device/dashboard-login", response_model=EspDashboardLoginResponse)
async def validate_dashboard_login(
login_data: LoginRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.email == login_data.email).first()
if (
not user
or not user.is_active
or user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN]
or not verify_password(login_data.password, user.hashed_password)
):
return {"valid": False}
return {
"valid": True,
"user_id": user.id,
"role": user.role,
"user_name": f"{user.first_name} {user.last_name}",
}
@router.get("/device/write-jobs/next", response_model=RfidWriteJobResponse | None)
async def get_next_write_job(
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
if not reader.can_write_cards:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reader is not enabled for card writing")
job = (
db.query(RfidCardWriteJob)
.filter(
RfidCardWriteJob.reader_id == reader.id,
RfidCardWriteJob.status == RfidWriteJobStatus.PENDING,
)
.order_by(RfidCardWriteJob.created_at.asc())
.first()
)
if not job:
return None
user = db.query(User).filter(User.id == job.user_id).first()
job.status = RfidWriteJobStatus.CLAIMED
job.claimed_at = utc_now()
job.write_payload = json.dumps(
{
"job_id": job.id,
"user_id": job.user_id,
"user_name": f"{user.first_name} {user.last_name}" if user else None,
"label": job.label,
"issued_at": to_zulu_iso(job.claimed_at),
}
)
db.commit()
db.refresh(job)
return job
@router.post("/device/write-jobs/{job_id}/complete", response_model=RfidWriteJobResponse)
async def complete_write_job(
job_id: int,
completion: RfidWriteJobCompleteRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
job = (
db.query(RfidCardWriteJob)
.filter(RfidCardWriteJob.id == job_id, RfidCardWriteJob.reader_id == reader.id)
.first()
)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found")
if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Write job is already closed")
now = utc_now()
if not completion.success:
job.status = RfidWriteJobStatus.FAILED
job.error_message = completion.error_message or "Reader reported write failure"
job.completed_at = now
job.updated_at = now
db.commit()
db.refresh(job)
return job
if not completion.card_uid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="card_uid is required when success is true")
card_uid = _normalize_card_uid(completion.card_uid)
card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first()
if card:
card.user_id = job.user_id
card.label = job.label
card.is_active = True
card.updated_at = now
else:
card = RfidCard(uid=card_uid, user_id=job.user_id, label=job.label, is_active=True)
db.add(card)
db.flush()
job.card_id = card.id
job.card_uid = card_uid
job.status = RfidWriteJobStatus.COMPLETED
job.completed_at = now
job.updated_at = now
db.commit()
db.refresh(job)
return job
@router.get("/admin/readers", response_model=List[EspReaderResponse])
async def list_readers(
include_inactive: bool = Query(True),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(EspReader)
if not include_inactive:
query = query.filter(EspReader.is_active == True)
return query.order_by(EspReader.name.asc()).all()
@router.post("/admin/readers", response_model=EspReaderCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_reader(
reader_data: EspReaderCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
existing = db.query(EspReader).filter(EspReader.device_id == reader_data.device_id).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Device ID already exists")
api_key = reader_data.api_key or _new_api_key()
reader = EspReader(
device_id=reader_data.device_id,
name=reader_data.name,
location=reader_data.location,
reader_type=reader_data.reader_type,
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
api_key_hash=get_machine_token_hash(api_key),
is_active=reader_data.is_active,
can_write_cards=reader_data.can_write_cards,
firmware_version=reader_data.firmware_version,
approved_at=utc_now(),
provisioned_at=utc_now(),
notes=reader_data.notes,
)
db.add(reader)
db.commit()
db.refresh(reader)
return EspReaderCreateResponse(
**EspReaderResponse.model_validate(reader).model_dump(),
api_key=api_key,
)
@router.post("/admin/readers/{reader_id}/approve", response_model=EspReaderResponse)
async def approve_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
if reader.provisioning_status == EspReaderProvisioningStatus.REJECTED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Rejected reader must register again")
now = utc_now()
reader.provisioning_status = EspReaderProvisioningStatus.APPROVED
reader.is_active = True
reader.approved_at = now
reader.updated_at = now
reader.api_key_hash = None
reader.pending_api_key = None
db.commit()
db.refresh(reader)
return reader
@router.post("/admin/readers/{reader_id}/reject", response_model=EspReaderResponse)
async def reject_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
reader.provisioning_status = EspReaderProvisioningStatus.REJECTED
reader.is_active = False
reader.pending_api_key = None
reader.updated_at = utc_now()
db.commit()
db.refresh(reader)
return reader
@router.put("/admin/readers/{reader_id}", response_model=EspReaderCreateResponse | EspReaderResponse)
async def update_reader(
reader_id: int,
reader_data: EspReaderUpdate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
update_data = reader_data.model_dump(exclude_unset=True)
rotate_api_key = update_data.pop("rotate_api_key", False)
for field, value in update_data.items():
setattr(reader, field, value)
new_api_key: Optional[str] = None
if rotate_api_key:
new_api_key = _new_api_key()
reader.api_key_hash = get_machine_token_hash(new_api_key)
reader.pending_api_key = None
reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED
reader.provisioned_at = utc_now()
reader.updated_at = utc_now()
db.commit()
db.refresh(reader)
if new_api_key:
return EspReaderCreateResponse(
**EspReaderResponse.model_validate(reader).model_dump(),
api_key=new_api_key,
)
return reader
@router.get("/admin/write-jobs", response_model=List[RfidWriteJobResponse])
async def list_write_jobs(
limit: int = Query(100, ge=1, le=500),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
return db.query(RfidCardWriteJob).order_by(RfidCardWriteJob.created_at.desc()).limit(limit).all()
@router.post("/admin/write-jobs", response_model=RfidWriteJobResponse, status_code=status.HTTP_201_CREATED)
async def queue_write_job(
job_data: RfidWriteJobCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == job_data.reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader does not exist")
provisioning_status = _provisioning_status_value(reader.provisioning_status)
if not reader.is_active or provisioning_status not in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not active and provisioned")
if not reader.can_write_cards:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not enabled for card writing")
user = db.query(User).filter(User.id == job_data.user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
job = RfidCardWriteJob(
reader_id=job_data.reader_id,
user_id=job_data.user_id,
label=job_data.label,
status=RfidWriteJobStatus.PENDING,
requested_by_user_id=admin_user.id,
)
db.add(job)
db.commit()
db.refresh(job)
return job
@router.post("/admin/write-jobs/{job_id}/cancel", response_model=RfidWriteJobResponse)
async def cancel_write_job(
job_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
job = db.query(RfidCardWriteJob).filter(RfidCardWriteJob.id == job_id).first()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found")
if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only pending or claimed jobs can be cancelled")
job.status = RfidWriteJobStatus.CANCELLED
job.updated_at = utc_now()
db.commit()
db.refresh(job)
return job
@router.get("/admin/cards", response_model=List[RfidCardResponse])
async def list_cards(
include_inactive: bool = Query(True),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(RfidCard)
if not include_inactive:
query = query.filter(RfidCard.is_active == True)
return query.order_by(RfidCard.uid.asc()).all()
@router.post("/admin/cards", response_model=RfidCardResponse, status_code=status.HTTP_201_CREATED)
async def create_card(
card_data: RfidCardCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
uid = _normalize_card_uid(card_data.uid)
existing = db.query(RfidCard).filter(RfidCard.uid == uid).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="RFID card already exists")
if card_data.user_id:
user = db.query(User).filter(User.id == card_data.user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
card = RfidCard(
uid=uid,
user_id=card_data.user_id,
label=card_data.label,
is_active=card_data.is_active,
)
db.add(card)
db.commit()
db.refresh(card)
return card
@router.put("/admin/cards/{card_id}", response_model=RfidCardResponse)
async def update_card(
card_id: int,
card_data: RfidCardUpdate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
card = db.query(RfidCard).filter(RfidCard.id == card_id).first()
if not card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="RFID card not found")
update_data = card_data.model_dump(exclude_unset=True)
if update_data.get("user_id"):
user = db.query(User).filter(User.id == update_data["user_id"]).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
for field, value in update_data.items():
setattr(card, field, value)
card.updated_at = utc_now()
db.commit()
db.refresh(card)
return card
@router.get("/admin/taps", response_model=List[RfidTapAdminResponse])
async def list_taps(
limit: int = Query(100, ge=1, le=500),
reader_id: Optional[int] = None,
user_id: Optional[int] = None,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(RfidTap)
if reader_id:
query = query.filter(RfidTap.reader_id == reader_id)
if user_id:
query = query.filter(RfidTap.user_id == user_id)
return query.order_by(RfidTap.tapped_at.desc()).limit(limit).all()
@router.get("/admin/attendance", response_model=List[AttendanceSessionResponse])
async def list_attendance_sessions(
open_only: bool = False,
limit: int = Query(100, ge=1, le=500),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(AttendanceSession)
if open_only:
query = query.filter(AttendanceSession.is_open == True)
return query.order_by(AttendanceSession.checked_in_at.desc()).limit(limit).all()
@router.post("/admin/attendance/close-stale", response_model=StaleSessionCloseResponse)
async def close_stale_sessions(
request: StaleSessionCloseRequest,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
closed_count = close_stale_attendance_sessions(
db,
cutoff_date=request.cutoff_date,
checkout_hour=request.checkout_hour,
)
return {"closed_count": closed_count}
@router.delete("/admin/readers/{reader_id}", response_model=MessageResponse)
async def delete_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
db.query(AttendanceSession).filter(AttendanceSession.reader_id == reader.id).delete(synchronize_session=False)
db.query(RfidTap).filter(RfidTap.reader_id == reader.id).delete(synchronize_session=False)
db.query(RfidCardWriteJob).filter(RfidCardWriteJob.reader_id == reader.id).delete(synchronize_session=False)
db.delete(reader)
db.commit()
return {"message": "Reader deleted"}
+17 -9
View File
@@ -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
@@ -204,4 +212,4 @@ async def get_my_rsvps(
): ):
"""Get current user's RSVPs""" """Get current user's RSVPs"""
rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all() rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all()
return rsvps return rsvps
+6 -5
View File
@@ -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"}
+6 -5
View File
@@ -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)
+642 -17
View File
@@ -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)
@@ -27,25 +200,123 @@ async def update_current_user_profile(
): ):
"""Update current user's profile""" """Update current user's profile"""
update_data = user_update.model_dump(exclude_unset=True) update_data = user_update.model_dump(exclude_unset=True)
# Check email uniqueness if email is being updated # Prevent privilege and volunteer-level edits through self-service profile endpoint.
if 'email' in update_data and update_data['email'] != current_user.email: update_data.pop("role", None)
existing_user = db.query(User).filter(User.email == update_data['email']).first() update_data.pop("volunteer_level", None)
if "email" in update_data and update_data["email"] != current_user.email:
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered" detail="Email already registered"
) )
for field, value in update_data.items(): for field, value in update_data.items():
setattr(current_user, field, value) setattr(current_user, field, value)
db.commit() db.commit()
db.refresh(current_user) db.refresh(current_user)
return current_user return current_user
@router.get("/me/profile-questions", response_model=List[ProfileQuestionForUser])
async def list_my_profile_questions(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
questions = db.query(ProfileQuestion).filter(ProfileQuestion.is_active == True).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == current_user.id).all()
answers_by_question = {answer.question_id: answer for answer in answers}
response: list[ProfileQuestionForUser] = []
for question in questions:
user_answer = answers_by_question.get(question.id)
can_edit = (not question.admin_only_edit) or (current_user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN])
response.append(
ProfileQuestionForUser(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
answer=_deserialize_answer_value(question, user_answer.value_text if user_answer else None),
can_edit=can_edit,
)
)
return response
@router.put("/me/profile-answers", response_model=MessageResponse)
async def update_my_profile_answers(
payload: ProfileAnswersUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
if not payload.answers:
return {"message": "No changes submitted"}
question_ids = {item.question_id for item in payload.answers}
questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids), ProfileQuestion.is_active == True).all()
questions_by_id = {question.id: question for question in questions}
missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id]
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Questions not found: {missing_ids}"
)
for item in payload.answers:
question = questions_by_id[item.question_id]
if question.admin_only_edit:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Question '{question.label}' can only be changed by admins"
)
normalized_value = _normalize_answer_value(question, item.value)
answer = db.query(UserProfileAnswer).filter(
UserProfileAnswer.user_id == current_user.id,
UserProfileAnswer.question_id == question.id
).first()
if normalized_value is None:
if answer:
db.delete(answer)
continue
if answer:
answer.value_text = normalized_value
answer.updated_by_user_id = current_user.id
else:
db.add(UserProfileAnswer(
user_id=current_user.id,
question_id=question.id,
value_text=normalized_value,
updated_by_user_id=current_user.id,
))
db.commit()
return {"message": "Profile answers updated successfully"}
@router.get("/", response_model=List[UserResponse]) @router.get("/", response_model=List[UserResponse])
async def list_users( async def list_users(
skip: int = 0, skip: int = 0,
@@ -58,6 +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,
@@ -88,18 +634,97 @@ async def update_user(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" detail="User not found"
) )
update_data = user_update.model_dump(exclude_unset=True) update_data = user_update.model_dump(exclude_unset=True)
if "email" in update_data and update_data["email"] != user.email:
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
if "role" in update_data and update_data["role"] == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only super admins can assign super admin role"
)
if "volunteer_level" in update_data:
update_data["volunteer_level"] = _normalize_volunteer_level(update_data["volunteer_level"])
for field, value in update_data.items(): for field, value in update_data.items():
setattr(user, field, value) setattr(user, field, value)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return user return user
@router.post("/{user_id}/send-password-reset", response_model=MessageResponse)
async def send_user_password_reset(
user_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Send a one-time password reset link email for a user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only super admins can send password reset emails for admin users"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reset password for inactive user"
)
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > 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,
@@ -113,8 +738,8 @@ async def delete_user(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" detail="User not found"
) )
db.delete(user) db.delete(user)
db.commit() db.commit()
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}
+12 -5
View File
@@ -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 -2
View File
@@ -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(
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timezone
def utc_now() -> datetime:
"""Naive UTC datetime for existing SQLAlchemy DateTime columns."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def to_utc_naive(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def to_zulu_iso(value: datetime) -> str:
return to_utc_aware(value).isoformat().replace("+00:00", "Z")
def unix_ms_utc(value: datetime) -> int:
return int(to_utc_aware(value).timestamp() * 1000)
+99 -1
View File
@@ -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")
+26 -2
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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
View File
@@ -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)
+60
View File
@@ -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
View File
@@ -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
@@ -0,0 +1,42 @@
from datetime import date, datetime, time, timedelta
from sqlalchemy.orm import Session
from ..core.datetime import utc_now
from ..models.models import AttendanceCheckoutSource, AttendanceSession
def duration_seconds(start: datetime, end: datetime) -> int:
return max(0, int((end - start).total_seconds()))
def close_stale_attendance_sessions(
db: Session,
cutoff_date: date | None = None,
checkout_hour: int = 17,
) -> int:
cutoff = cutoff_date or date.today()
cutoff_at = datetime.combine(cutoff, time.min)
sessions = (
db.query(AttendanceSession)
.filter(
AttendanceSession.is_open == True,
AttendanceSession.checked_in_at < cutoff_at,
)
.all()
)
now = utc_now()
for session in sessions:
checkout_at = datetime.combine(session.checked_in_at.date(), time(hour=checkout_hour))
if checkout_at < session.checked_in_at:
checkout_at = session.checked_in_at + timedelta(minutes=1)
session.checked_out_at = checkout_at
session.checkout_source = AttendanceCheckoutSource.SYSTEM
session.system_flag_reason = "User did not check out before midnight; checkout time was system-set."
session.duration_seconds = duration_seconds(session.checked_in_at, checkout_at)
session.is_open = False
session.updated_at = now
db.commit()
return len(sessions)
+12 -8
View File
@@ -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,18 +256,18 @@ 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
# Create a singleton instance # Create a singleton instance
bounce_service = BounceService() bounce_service = BounceService()
+2 -1
View File
@@ -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)
+7
View File
@@ -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))
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime
from app.core.datetime import unix_ms_utc, utc_now
from app.schemas import EventCreate, EventResponse, EspTimeResponse
def test_event_input_datetime_is_normalized_to_utc_naive() -> None:
event = EventCreate(
title="Evening briefing",
event_date="2026-06-01T19:30:00+01:00",
event_time=None,
)
assert event.event_date == datetime(2026, 6, 1, 18, 30)
assert event.event_date.tzinfo is None
def test_response_datetimes_serialize_as_zulu() -> None:
event = EventResponse(
id=1,
title="Evening briefing",
description=None,
event_date=datetime(2026, 6, 1, 18, 30),
event_time=None,
location=None,
max_attendees=None,
status="draft",
created_by=1,
created_at=datetime(2026, 5, 1, 10, 0),
updated_at=datetime(2026, 5, 1, 10, 0),
)
payload = event.model_dump_json()
assert '"event_date":"2026-06-01T18:30:00Z"' in payload
assert '"created_at":"2026-05-01T10:00:00Z"' in payload
def test_esp_time_uses_same_utc_instant_for_iso_and_unix_ms() -> None:
now = utc_now()
response = EspTimeResponse(
server_time_utc=now,
unix_ms=unix_ms_utc(now),
)
assert '"server_time_utc":"' in response.model_dump_json()
assert response.unix_ms == unix_ms_utc(now)
+186
View File
@@ -0,0 +1,186 @@
import asyncio
import json
from types import SimpleNamespace
from app.api.v1 import esp
from app.core.security import get_machine_token_hash, get_password_hash, verify_machine_token
from app.models.models import EspReaderProvisioningStatus
from app.schemas import EspReaderRegistrationRequest
class _FakeDb:
def __init__(self, reader=None) -> None:
self.reader = reader
class _Query:
def __init__(self, reader) -> None:
self.reader = reader
def filter(self, *args, **kwargs):
return self
def first(self):
return self.reader
def commit(self) -> None:
return None
def add(self, _obj) -> None:
return None
def refresh(self, _obj) -> None:
return None
def query(self, _model):
return self._Query(self.reader)
def test_provisioning_status_returns_api_key_for_enum_status(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status=EspReaderProvisioningStatus.APPROVED,
pending_api_key=None,
api_key_hash=None,
provisioned_at=None,
updated_at=None,
)
monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader)
monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key")
response = asyncio.run(
esp.get_provisioning_status(
x_esp_device_id="esp32-123456",
x_esp_registration_token="token",
db=_FakeDb(),
)
)
payload = json.loads(response.body)
assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value
assert payload["api_key"] == "generated-api-key"
assert payload["apiKey"] == "generated-api-key"
def test_register_reader_allows_recovery_before_first_authenticated_call(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
name="Old Reader",
location="Old Location",
reader_type="checkin_checkout",
can_write_cards=False,
firmware_version="old-fw",
notes="old",
registration_token_hash="old-hash",
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
is_active=True,
pending_api_key="pending-api-key",
last_seen_at=None,
updated_at=None,
)
db = _FakeDb(reader)
monkeypatch.setattr(esp, "_new_registration_token", lambda: "replacement-token")
monkeypatch.setattr(esp, "get_machine_token_hash", lambda value: f"hashed:{value}")
response = asyncio.run(
esp.register_reader(
EspReaderRegistrationRequest(
device_id="esp32-123456",
name="Recovered Reader",
location="Front Desk",
reader_type="checkin_checkout",
can_write_cards=True,
firmware_version="new-fw",
notes="recovered",
),
db=db,
)
)
assert response["provisioning_status"] == EspReaderProvisioningStatus.APPROVED
assert response["registration_token"] == "replacement-token"
assert response["message"] == "Reader recovery accepted. Poll provisioning to receive the API key again."
assert reader.registration_token_hash == "hashed:replacement-token"
assert reader.pending_api_key == "pending-api-key"
assert reader.provisioning_status == EspReaderProvisioningStatus.APPROVED
def test_machine_token_hash_round_trip() -> None:
token = "esp-device-token"
stored_hash = get_machine_token_hash(token)
assert verify_machine_token(token, stored_hash) is True
assert verify_machine_token("wrong-token", stored_hash) is False
def test_machine_token_verify_supports_legacy_bcrypt_hash() -> None:
token = "legacy-esp-token"
stored_hash = get_password_hash(token)
assert verify_machine_token(token, stored_hash) is True
assert verify_machine_token("wrong-token", stored_hash) is False
def test_get_current_reader_migrates_legacy_bcrypt_api_key() -> None:
api_key = "legacy-api-key"
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
is_active=True,
api_key_hash=get_password_hash(api_key),
pending_api_key=None,
last_seen_at=None,
)
db = _FakeDb(reader)
response_reader = asyncio.run(
esp.get_current_reader(
x_esp_device_id="esp32-123456",
x_esp_api_key=api_key,
db=db,
)
)
assert response_reader is reader
assert reader.api_key_hash == get_machine_token_hash(api_key)
def test_compact_tap_response_uses_short_keys() -> None:
tap = SimpleNamespace(
accepted=True,
action=SimpleNamespace(value="check_in"),
message="Checked in",
)
response = esp._compact_tap_response(tap)
payload = json.loads(response.body)
assert payload == {"ok": True, "a": "check_in", "m": "Checked in"}
def test_provisioning_status_returns_api_key_for_string_status(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status="approved",
pending_api_key=None,
api_key_hash=None,
provisioned_at=None,
updated_at=None,
)
monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader)
monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key")
response = asyncio.run(
esp.get_provisioning_status(
x_esp_device_id="esp32-123456",
x_esp_registration_token="token",
db=_FakeDb(),
)
)
payload = json.loads(response.body)
assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value
assert payload["api_key"] == "generated-api-key"
assert payload["apiKey"] == "generated-api-key"
@@ -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
+4 -1
View File
@@ -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
+3411
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+44 -14
View File
@@ -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;
+176 -224
View File
@@ -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,9 +282,20 @@ 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>
); );
}; };
export default BounceManagement; export default BounceManagement;
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import axios from 'axios'; import 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,200 +290,188 @@ 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>
); );
}; };
export default EmailTemplateManagement; export default EmailTemplateManagement;
File diff suppressed because it is too large Load Diff
+32 -35
View File
@@ -5,30 +5,26 @@ 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]) => (
<div <div
@@ -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)}
@@ -61,20 +62,16 @@ const FeatureFlagStatus: React.FC = () => {
</div> </div>
))} ))}
</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>
); );
}; };
export default FeatureFlagStatus; export default FeatureFlagStatus;
+55 -70
View File
@@ -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"
@@ -354,4 +339,4 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
return null; return null;
}; };
export default MembershipSetup; export default MembershipSetup;
+26 -128
View File
@@ -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>
@@ -322,4 +220,4 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
); );
}; };
export default ProfileMenu; export default ProfileMenu;
@@ -0,0 +1,270 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ProfileAnswerInput, ProfileQuestionForUser } from '../services/membershipService';
import {
answerToComparable,
canEditProfileQuestion,
isProfileQuestionVisible,
ProfileQuestionAnswerValue
} from '../utils/profileQuestionLogic';
interface ProfileQuestionsFormProps {
title: string;
description?: string;
questions: ProfileQuestionForUser[];
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
saveLabel?: string;
allowAdminManagedEdit?: boolean;
surface?: 'member' | 'admin';
}
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
if (value === null || value === undefined || value === '') return 'Not set';
if (question.input_type === 'boolean') return value === true || value === 'true' ? 'Yes' : 'No';
if (question.input_type === 'select') {
const matchingOption = question.options.find((option) => option.value === String(value));
return matchingOption?.label || String(value);
}
return String(value);
};
const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
title,
description,
questions,
onSave,
saveLabel = 'Save Answers',
allowAdminManagedEdit = false,
surface = 'admin'
}) => {
const initialAnswers = useMemo(() => {
const values: Record<number, ProfileQuestionAnswerValue> = {};
questions.forEach((question) => {
values[question.id] = question.answer ?? null;
});
return values;
}, [questions]);
const [answers, setAnswers] = useState<Record<number, ProfileQuestionAnswerValue>>(initialAnswers);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
setAnswers(initialAnswers);
setSuccessMessage(null);
setError(null);
}, [initialAnswers]);
const visibleQuestions = useMemo(() => {
const byId = new Map<number, ProfileQuestionForUser>();
questions.forEach((question) => byId.set(question.id, question));
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
}, [questions, answers]);
const filteredQuestions = useMemo(() => {
const searchTerm = search.trim().toLowerCase();
return visibleQuestions.filter((question) => {
if (!searchTerm) return true;
return (
question.label.toLowerCase().includes(searchTerm) ||
question.key.toLowerCase().includes(searchTerm) ||
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
}, [visibleQuestions, search]);
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const paginatedQuestions = useMemo(() => {
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page, totalPages]);
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
setSuccessMessage(null);
setError(null);
};
useEffect(() => {
setPage(1);
}, [search]);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
const changedAnswers: ProfileAnswerInput[] = questions
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
.filter((question) => answerToComparable(answers[question.id] ?? null) !== answerToComparable(initialAnswers[question.id] ?? null))
.map((question) => ({
question_id: question.id,
value: answers[question.id] ?? null
}));
await onSave(changedAnswers);
setSuccessMessage(changedAnswers.length > 0 ? 'Saved successfully.' : 'No changes to save.');
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save answers.');
} finally {
setSaving(false);
}
};
const renderField = (question: ProfileQuestionForUser) => {
const value = answers[question.id] ?? null;
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
if (disabled && !saving) {
return <div className="profile-question-readonly">{formatAnswerForDisplay(question, value)}</div>;
}
if (question.input_type === 'boolean') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : event.target.value === 'true')}
disabled={disabled}
className="profile-question-input"
>
<option value="">Prefer not to say</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (question.input_type === 'select') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
className="profile-question-input"
>
<option value="">Select an option</option>
{question.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
);
}
if (question.input_type === 'date') {
return (
<input
type="date"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
className="profile-question-input"
/>
);
}
if (question.input_type === 'number') {
return (
<input
type="number"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
disabled={disabled}
placeholder={question.placeholder || ''}
className="profile-question-input"
/>
);
}
return (
<input
type="text"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
placeholder={question.placeholder || ''}
className="profile-question-input"
/>
);
};
return (
<div className={`card profile-questions-form ${surface === 'member' ? 'member-surface' : 'admin-surface'}`}>
<h3 className="profile-questions-title">{title}</h3>
{description && <p className="profile-questions-description">{description}</p>}
<div className="profile-questions-search">
<input
type="text"
placeholder="Search questions..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="profile-question-input"
/>
</div>
{error && <div className="alert alert-error">{error}</div>}
{successMessage && <div className="alert alert-success">{successMessage}</div>}
{filteredQuestions.length === 0 ? (
<p className="profile-questions-empty">No questions available.</p>
) : (
<div className="profile-questions-list">
{paginatedQuestions.map((question) => (
<div
key={question.id}
className={`profile-question-row ${surface === 'member' ? 'profile-question-row-member' : 'profile-question-row-admin'}`}
>
<div className="profile-question-meta">
<label className="profile-question-label">
{question.label}
{question.is_required && <span className="profile-question-required"> *</span>}
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
</label>
{question.help_text && (
<p className="profile-question-help">{question.help_text}</p>
)}
</div>
<div className="profile-question-answer">{renderField(question)}</div>
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
<p className="profile-question-lock-note">
This field can only be changed by an admin.
</p>
)}
</div>
))}
</div>
)}
{filteredQuestions.length > pageSize && (
<div className="profile-questions-pagination">
<span className="profile-questions-page-copy">
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
</span>
<div className="profile-questions-pager-buttons">
<button className="btn btn-secondary profile-questions-pager-button" disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
Previous
</button>
<button className="btn btn-secondary profile-questions-pager-button" disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
Next
</button>
</div>
</div>
)}
<div className="profile-questions-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : saveLabel}
</button>
</div>
</div>
);
};
export default ProfileQuestionsForm;
+71 -89
View File
@@ -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}
@@ -384,4 +366,4 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
); );
}; };
export default SuperAdminMenu; export default SuperAdminMenu;
@@ -0,0 +1,77 @@
import React from 'react';
import { User } from '../../services/membershipService';
import ProfileMenu from '../ProfileMenu';
import PortalBrand from '../layout/PortalBrand';
interface DashboardTopbarProps {
activeTab: 'overview' | 'questions' | 'settings' | 'admin';
isAdmin: boolean;
isAdminWorkspace: boolean;
navigateToTab: (tab: 'overview' | 'questions' | 'settings' | 'admin') => void;
enterAdminArea: () => void;
exitAdminArea: () => void;
onEditProfile: () => void;
subtitle?: string;
user: User | null;
}
const userTabs: Array<{ key: 'overview' | 'questions' | 'settings'; label: string }> = [
{ key: 'overview', label: 'Overview' },
{ key: 'questions', label: 'Profile Questions' },
{ key: 'settings', label: 'Profile Settings' }
];
const DashboardTopbar: React.FC<DashboardTopbarProps> = ({
activeTab,
isAdmin,
isAdminWorkspace,
navigateToTab,
enterAdminArea,
exitAdminArea,
onEditProfile,
subtitle,
user
}) => (
<nav className={isAdminWorkspace ? 'portal-topbar portal-topbar-admin' : 'portal-topbar member-topbar'}>
<PortalBrand
title={isAdminWorkspace ? 'SASA Admin' : 'SASA Member Portal'}
subtitle={subtitle || (isAdminWorkspace ? 'Operations network' : `Welcome, ${user?.first_name || 'Member'}`)}
admin={isAdminWorkspace}
/>
{!isAdminWorkspace && (
<div className="portal-nav">
{userTabs.map((tab) => (
<button
key={tab.key}
className={activeTab === tab.key ? 'portal-tab active' : 'portal-tab'}
onClick={() => navigateToTab(tab.key)}
>
{tab.label}
</button>
))}
{isAdmin && (
<button className="portal-switch-button" onClick={enterAdminArea}>
Enter Admin Area
</button>
)}
</div>
)}
<div className="portal-meta">
{isAdminWorkspace && (
<button className="portal-exit-button" onClick={exitAdminArea}>
Back to User Space
</button>
)}
<ProfileMenu
userName={`${user?.first_name || ''} ${user?.last_name || ''}`.trim()}
userRole={user?.role || ''}
user={user}
onEditProfile={onEditProfile}
/>
</div>
</nav>
);
export default DashboardTopbar;
@@ -0,0 +1,14 @@
import React from 'react';
import { Link } from 'react-router-dom';
const AppFooter: React.FC = () => (
<footer className="site-footer">
<div>
<Link to="/privacy-policy">Privacy Policy</Link>
<Link to="/terms-of-service">Terms of Service</Link>
</div>
<div className="site-footer-caption">SASA Portal</div>
</footer>
);
export default AppFooter;
@@ -0,0 +1,18 @@
import React from 'react';
interface CookieBannerProps {
onDismiss: () => void;
}
const CookieBanner: React.FC<CookieBannerProps> = ({ onDismiss }) => (
<div className="cookie-banner">
<div>
We use cookies for session authentication, security, and basic site functionality.
</div>
<button className="btn btn-primary cookie-banner-button" onClick={onDismiss}>
OK
</button>
</div>
);
export default CookieBanner;
@@ -0,0 +1,19 @@
import React from 'react';
interface PortalBrandProps {
title: string;
subtitle: string;
admin?: boolean;
}
const PortalBrand: React.FC<PortalBrandProps> = ({ title, subtitle, admin = false }) => (
<div className="portal-brand">
<div className="portal-mark">S</div>
<div className={`portal-brand-text${admin ? ' admin-brand-text' : ''}`}>
<h1>{title}</h1>
<div className="portal-subtitle">{subtitle}</div>
</div>
</div>
);
export default PortalBrand;
+108
View File
@@ -0,0 +1,108 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'default' | 'danger';
}
interface ConfirmState extends ConfirmOptions {
open: boolean;
}
interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const lastLocationKeyRef = useRef(location.key);
const [dialog, setDialog] = useState<ConfirmState>({
open: false,
title: '',
message: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
tone: 'default'
});
const closeDialog = useCallback((result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setDialog((prev) => ({ ...prev, open: false }));
}, []);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
setDialog({
open: true,
title: options.title || 'Confirm action',
message: options.message,
confirmLabel: options.confirmLabel || 'Confirm',
cancelLabel: options.cancelLabel || 'Cancel',
tone: options.tone || 'default'
});
});
}, []);
const value = useMemo<ConfirmContextValue>(() => ({ confirm }), [confirm]);
useEffect(() => {
if (lastLocationKeyRef.current === location.key) {
return;
}
lastLocationKeyRef.current = location.key;
if (!dialog.open) {
return;
}
resolverRef.current?.(false);
resolverRef.current = null;
setDialog((prev) => ({ ...prev, open: false }));
}, [dialog.open, location.key]);
return (
<ConfirmContext.Provider value={value}>
{children}
{dialog.open && (
<div className="modal-overlay" onClick={() => closeDialog(false)}>
<div className="modal-content confirm-dialog" onClick={(event) => event.stopPropagation()}>
<h3 className={dialog.tone === 'danger' ? 'confirm-dialog-title danger' : 'confirm-dialog-title'}>
{dialog.title}
</h3>
<p className="confirm-dialog-message">{dialog.message}</p>
<div className="modal-button-row">
<button className="btn btn-secondary" type="button" onClick={() => closeDialog(false)}>
{dialog.cancelLabel}
</button>
<button
className={dialog.tone === 'danger' ? 'btn btn-danger' : 'btn btn-primary'}
type="button"
onClick={() => closeDialog(true)}
>
{dialog.confirmLabel}
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
);
};
export const useConfirm = (): ConfirmContextValue => {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return context;
};
+65
View File
@@ -0,0 +1,65 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
type ToastTone = 'success' | 'error' | 'info';
interface ToastItem {
id: number;
message: string;
tone: ToastTone;
}
interface ToastContextValue {
showToast: (message: string, tone?: ToastTone) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((message: string, tone: ToastTone = 'info') => {
const id = Date.now() + Math.floor(Math.random() * 1000);
setToasts((prev) => [...prev, { id, message, tone }]);
window.setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 5000);
}, []);
const value = useMemo<ToastContextValue>(() => ({
showToast,
success: (message) => showToast(message, 'success'),
error: (message) => showToast(message, 'error'),
info: (message) => showToast(message, 'info')
}), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-viewport" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.tone}`}>
<div className="toast-message">{toast.message}</div>
<button className="toast-close" type="button" onClick={() => dismissToast(toast.id)} aria-label="Dismiss notification">
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
File diff suppressed because it is too large Load Diff
+58 -36
View File
@@ -26,48 +26,70 @@ 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&apos;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>
); );
}; };
export default ForgotPassword; export default ForgotPassword;
+81 -76
View File
@@ -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&apos;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>
); );
}; };
+16 -6
View File
@@ -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.');
} }
}; };
@@ -393,4 +403,4 @@ const MembershipTierForm: React.FC<MembershipTierFormProps> = ({ tier, onSave, o
); );
}; };
export default MembershipTiers; export default MembershipTiers;
+16
View File
@@ -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
View File
@@ -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>
); );
}; };
+102 -57
View File
@@ -55,74 +55,119 @@ 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 cant 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&apos;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>
); );
}; };
export default ResetPassword; export default ResetPassword;
+16
View File
@@ -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;
@@ -0,0 +1,81 @@
import React from 'react';
type AdminAreaKey = 'operations' | 'rfid' | 'comms' | 'flags' | 'tiers';
type AdminSectionKey =
| 'overview'
| 'users'
| 'events'
| 'profileQuestions'
| 'espActions'
| 'espReaders'
| 'espCards'
| 'espActivity'
| 'featureFlags'
| 'tiers'
| 'email'
| 'bounces';
interface AdminWorkspacePageProps {
activeAdminArea: AdminAreaKey;
activePageItems: Array<{ key: AdminSectionKey; label: string }>;
adminPrimaryItems: Array<{ key: AdminAreaKey; label: string; defaultSection: AdminSectionKey }>;
adminSection: AdminSectionKey;
children: React.ReactNode;
renderAdminRailTools: () => React.ReactNode;
renderPrimaryIcon: (area: AdminAreaKey) => React.ReactNode;
showAdminPageRail: boolean;
navigateToAdminSection: (section: AdminSectionKey) => void;
}
const AdminWorkspacePage: React.FC<AdminWorkspacePageProps> = ({
activeAdminArea,
activePageItems,
adminPrimaryItems,
adminSection,
children,
renderAdminRailTools,
renderPrimaryIcon,
showAdminPageRail,
navigateToAdminSection
}) => (
<div className={`admin-workspace ${showAdminPageRail ? 'has-page-rail' : 'single-page-area'}`}>
<aside className="admin-primary-rail">
<nav className="admin-primary-nav" aria-label="Admin areas">
{adminPrimaryItems.map((item) => (
<button
key={item.key}
className={activeAdminArea === item.key ? 'admin-primary-link active' : 'admin-primary-link'}
onClick={() => navigateToAdminSection(item.defaultSection)}
title={item.label}
>
<span className="admin-primary-icon">{renderPrimaryIcon(item.key)}</span>
</button>
))}
</nav>
</aside>
{showAdminPageRail && (
<aside className="admin-page-rail">
<div className="admin-page-rail-title">
{adminPrimaryItems.find((item) => item.key === activeAdminArea)?.label}
</div>
<nav className="admin-page-nav" aria-label="Admin pages">
{activePageItems.map((item) => (
<button
key={item.key}
className={adminSection === item.key ? 'admin-page-link active' : 'admin-page-link'}
onClick={() => navigateToAdminSection(item.key)}
>
{item.label}
</button>
))}
</nav>
{renderAdminRailTools()}
</aside>
)}
<section className="admin-content">{children}</section>
</div>
);
export default AdminWorkspacePage;
@@ -0,0 +1,189 @@
import React from 'react';
import { Event, Membership, Payment } from '../../services/membershipService';
import { utcToLondonTimeInput } from '../../utils/timezone';
interface MemberOverviewPageProps {
activeMembership?: Membership;
formatDate: (dateString: string) => string;
getStatusClass: (status: string) => string;
handleMembershipSetup: () => void;
handleRSVP: (eventId: number, status: 'attending' | 'maybe' | 'not_attending') => void;
payments: Payment[];
rsvpLoading: { [eventId: number]: boolean };
upcomingEvents: Event[];
}
const MemberOverviewPage: React.FC<MemberOverviewPageProps> = ({
activeMembership,
formatDate,
getStatusClass,
handleMembershipSetup,
handleRSVP,
payments,
rsvpLoading,
upcomingEvents
}) => (
<>
<section className="member-hero">
<div>
<p className="member-hero-kicker">Member Dashboard</p>
<h2 className="member-hero-title">Everything you need for your SASA membership</h2>
<p className="member-hero-copy">
Track your status, respond to upcoming events, and keep your details current from one place.
</p>
</div>
<div className="member-stat-strip">
<div className="member-stat-chip">
<span className="member-stat-label">Membership</span>
<strong className="member-stat-value">{activeMembership ? activeMembership.status : 'Not set up'}</strong>
</div>
<div className="member-stat-chip">
<span className="member-stat-label">Events</span>
<strong className="member-stat-value">{upcomingEvents.length}</strong>
</div>
<div className="member-stat-chip">
<span className="member-stat-label">Payments</span>
<strong className="member-stat-value">{payments.length}</strong>
</div>
</div>
</section>
<div className="dashboard-grid member-overview-grid">
{activeMembership ? (
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Membership</p>
<h3>Your Membership</h3>
</div>
<span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span>
</div>
<h4 className="member-tier-title">{activeMembership.tier.name}</h4>
<div className="member-data-list">
<div className="member-data-row"><strong>Membership Number</strong><span>{activeMembership.id}</span></div>
<div className="member-data-row"><strong>Annual Fee</strong><span>£{activeMembership.tier.annual_fee.toFixed(2)}</span></div>
<div className="member-data-row"><strong>Valid From</strong><span>{formatDate(activeMembership.start_date)}</span></div>
<div className="member-data-row"><strong>Valid Until</strong><span>{formatDate(activeMembership.end_date)}</span></div>
<div className="member-data-row"><strong>Auto Renew</strong><span>{activeMembership.auto_renew ? 'Yes' : 'No'}</span></div>
</div>
<div className="member-info-panel">
<strong>Benefits:</strong>
<p>{activeMembership.tier.benefits}</p>
</div>
</div>
) : (
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Membership</p>
<h3>Set Up Your Membership</h3>
</div>
</div>
<p>Choose from our membership tiers to get started with SASA benefits.</p>
<p className="member-muted-copy">Available tiers include Personal, Aircraft Owners, and Corporate memberships.</p>
<button className="btn btn-primary member-inline-action" onClick={handleMembershipSetup}>
Set Up Membership
</button>
</div>
)}
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Calendar</p>
<h3>Upcoming Events</h3>
</div>
</div>
{upcomingEvents.length > 0 ? (
<div className="events-container">
{upcomingEvents.map((event) => (
<div key={event.id} className="event-card">
<div className="event-header">
<div className="event-info">
<h4 className="event-title">{event.title}</h4>
<p className="event-datetime">
{formatDate(event.event_date)} at {utcToLondonTimeInput(event.event_date)}
</p>
{event.location && <p className="event-location">{event.location}</p>}
</div>
<div className="event-rsvp-buttons">
<button
className={`rsvp-btn rsvp-btn-attending ${event.rsvp_status === 'attending' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'attending')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Attending'}
</button>
<button
className={`rsvp-btn rsvp-btn-maybe ${event.rsvp_status === 'maybe' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'maybe')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Maybe'}
</button>
<button
className={`rsvp-btn rsvp-btn-not-attending ${event.rsvp_status === 'not_attending' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'not_attending')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
</button>
</div>
</div>
{event.description && <p className="event-description">{event.description}</p>}
{event.rsvp_status && (
<div className={`event-rsvp-status ${event.rsvp_status}`}>
<strong>Your RSVP:</strong> <span className="member-rsvp-state">{event.rsvp_status.replace('_', ' ')}</span>
</div>
)}
</div>
))}
</div>
) : (
<p className="member-muted-copy">No upcoming events at this time.</p>
)}
</div>
</div>
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Billing</p>
<h3>Payment History</h3>
</div>
</div>
{payments.length > 0 ? (
<div className="table-container">
<table className="member-table">
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Method</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{payments.map((payment) => (
<tr key={payment.id}>
<td>{payment.payment_date ? formatDate(payment.payment_date) : 'Pending'}</td>
<td>£{payment.amount.toFixed(2)}</td>
<td className="member-table-caps">{payment.payment_method}</td>
<td>
<span className={`status-badge ${getStatusClass(payment.status)}`}>
{payment.status.toUpperCase()}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="member-muted-copy">No payment history available.</p>
)}
</div>
</>
);
export default MemberOverviewPage;
@@ -0,0 +1,21 @@
import React from 'react';
import ProfileQuestionsForm from '../../components/ProfileQuestionsForm';
import { ProfileAnswerInput, ProfileQuestionForUser } from '../../services/membershipService';
interface MemberQuestionsPageProps {
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
questions: ProfileQuestionForUser[];
}
const MemberQuestionsPage: React.FC<MemberQuestionsPageProps> = ({ onSave, questions }) => (
<ProfileQuestionsForm
title="Your Profile Questions"
description="Optional details that help us support your membership and volunteering. Some fields are admin-managed."
questions={questions}
onSave={onSave}
saveLabel="Save Profile Answers"
surface="member"
/>
);
export default MemberQuestionsPage;
@@ -0,0 +1,165 @@
import React from 'react';
interface MemberSettingsPageProps {
passwordError: string;
passwordForm: {
current_password: string;
new_password: string;
confirm_password: string;
};
passwordSaving: boolean;
passwordSuccess: string;
profileError: string;
profileFormData: {
first_name: string;
last_name: string;
email: string;
phone: string;
address: string;
};
profileSaving: boolean;
profileSuccess: string;
setPasswordForm: React.Dispatch<React.SetStateAction<{
current_password: string;
new_password: string;
confirm_password: string;
}>>;
setProfileFormData: React.Dispatch<React.SetStateAction<{
first_name: string;
last_name: string;
email: string;
phone: string;
address: string;
}>>;
onChangePassword: () => void;
onSaveProfile: () => void;
}
const MemberSettingsPage: React.FC<MemberSettingsPageProps> = ({
passwordError,
passwordForm,
passwordSaving,
passwordSuccess,
profileError,
profileFormData,
profileSaving,
profileSuccess,
setPasswordForm,
setProfileFormData,
onChangePassword,
onSaveProfile
}) => (
<div className="card member-card member-settings-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Settings</p>
<h3>Profile Settings</h3>
</div>
</div>
{profileError && <div className="alert alert-error">{profileError}</div>}
{profileSuccess && <div className="alert alert-success">{profileSuccess}</div>}
<div className="member-settings-grid">
<div className="form-group">
<label htmlFor="settings-first-name">First Name</label>
<input
id="settings-first-name"
type="text"
placeholder="First Name"
value={profileFormData.first_name}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, first_name: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-last-name">Last Name</label>
<input
id="settings-last-name"
type="text"
placeholder="Last Name"
value={profileFormData.last_name}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, last_name: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-email">Email</label>
<input
id="settings-email"
type="email"
placeholder="Email"
value={profileFormData.email}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, email: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-phone">Phone</label>
<input
id="settings-phone"
type="text"
placeholder="Phone"
value={profileFormData.phone}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, phone: e.target.value }))}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="settings-address">Address</label>
<textarea
id="settings-address"
placeholder="Address"
value={profileFormData.address}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, address: e.target.value }))}
rows={3}
/>
</div>
<div className="member-settings-actions">
<button className="btn btn-primary" disabled={profileSaving} onClick={onSaveProfile}>
{profileSaving ? 'Saving...' : 'Save Profile'}
</button>
</div>
<div className="member-settings-divider" />
<h4 className="member-section-heading">Change Password</h4>
{passwordError && <div className="alert alert-error">{passwordError}</div>}
{passwordSuccess && <div className="alert alert-success">{passwordSuccess}</div>}
<div className="member-settings-grid">
<div className="form-group">
<label htmlFor="settings-current-password">Current Password</label>
<input
id="settings-current-password"
type="password"
placeholder="Current Password"
value={passwordForm.current_password}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, current_password: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-new-password">New Password</label>
<input
id="settings-new-password"
type="password"
placeholder="New Password"
value={passwordForm.new_password}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, new_password: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-confirm-password">Confirm New Password</label>
<input
id="settings-confirm-password"
type="password"
placeholder="Confirm New Password"
value={passwordForm.confirm_password}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirm_password: e.target.value }))}
/>
</div>
</div>
<div className="member-settings-actions">
<button className="btn btn-secondary" disabled={passwordSaving} onClick={onChangePassword}>
{passwordSaving ? 'Updating...' : 'Update Password'}
</button>
</div>
</div>
);
export default MemberSettingsPage;
+303 -2
View File
@@ -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,62 @@
export type ProfileQuestionAnswerValue = string | number | boolean | null;
export interface EditableProfileQuestion {
id: number;
admin_only_edit: boolean;
can_edit: boolean;
}
export interface DependentProfileQuestion {
id: number;
depends_on_question_id: number | null;
depends_on_value: string | null;
}
export const answerToComparable = (value: ProfileQuestionAnswerValue): string | null => {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return String(value);
};
export const canEditProfileQuestion = (
question: EditableProfileQuestion,
allowAdminManagedEdit = false
): boolean => {
if (!question.can_edit) {
return false;
}
if (question.admin_only_edit && !allowAdminManagedEdit) {
return false;
}
return true;
};
export const isProfileQuestionVisible = <TQuestion extends DependentProfileQuestion>(
question: TQuestion,
questionsById: Map<number, TQuestion>,
answers: Record<number, ProfileQuestionAnswerValue>
): boolean => {
if (!question.depends_on_question_id) {
return true;
}
const parentQuestion = questionsById.get(question.depends_on_question_id);
if (!parentQuestion) {
return true;
}
const parentAnswer = answerToComparable(answers[parentQuestion.id] ?? null);
if (question.depends_on_value === null || question.depends_on_value === undefined) {
return parentAnswer !== null && parentAnswer !== '';
}
return parentAnswer === question.depends_on_value;
};
+92
View File
@@ -0,0 +1,92 @@
const LONDON_TIME_ZONE = 'Europe/London';
const parseUtcDate = (value: string): Date => {
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value) ? value : `${value}Z`;
return new Date(normalized);
};
const londonParts = (date: Date) => {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(date);
const get = (type: string) => parts.find((part) => part.type === type)?.value || '00';
return {
year: get('year'),
month: get('month'),
day: get('day'),
hour: get('hour'),
minute: get('minute')
};
};
const timeZoneOffsetMs = (instant: Date): number => {
const parts = londonParts(instant);
const wallAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute)
);
return wallAsUtc - instant.getTime();
};
export const ensureUtcIso = (value: string): string => parseUtcDate(value).toISOString();
export const utcMillis = (value: string | null | undefined): number => {
if (!value) return 0;
return parseUtcDate(value).getTime();
};
export const formatLondonDate = (value: string): string => {
return new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(parseUtcDate(value));
};
export const formatLondonDateTime = (value: string | null): string => {
if (!value) return 'Never';
return new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).format(parseUtcDate(value));
};
export const utcToLondonDateInput = (value: string): string => {
const parts = londonParts(parseUtcDate(value));
return `${parts.year}-${parts.month}-${parts.day}`;
};
export const utcToLondonTimeInput = (value: string): string => {
const parts = londonParts(parseUtcDate(value));
return `${parts.hour}:${parts.minute}`;
};
export const londonTodayDateInput = (): string => {
const parts = londonParts(new Date());
return `${parts.year}-${parts.month}-${parts.day}`;
};
export const londonInputToUtcIso = (dateValue: string, timeValue: string = '00:00'): string => {
const [year, month, day] = dateValue.split('-').map(Number);
const [hour, minute] = timeValue.split(':').map(Number);
const wallAsUtc = Date.UTC(year, month - 1, day, hour || 0, minute || 0);
const firstPass = new Date(wallAsUtc - timeZoneOffsetMs(new Date(wallAsUtc)));
const secondPass = new Date(wallAsUtc - timeZoneOffsetMs(firstPass));
return secondPass.toISOString();
};
+9 -3
View File
@@ -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
View File
@@ -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