forked from jamesp/sasa-membership
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a7ecf67b5 | |||
| 2d5bdcbe35 | |||
| 000555dbd7 | |||
| d024bf7fa3 |
@@ -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.*
|
||||||
@@ -24,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
@@ -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.
|
||||||
+3
-1
@@ -6,7 +6,7 @@ This project aims to develop a comprehensive membership management system for th
|
|||||||
|
|
||||||
## Current Implementation Status
|
## Current Implementation Status
|
||||||
|
|
||||||
The app now includes a FastAPI backend, React/Vite frontend, Docker Compose development gateway, Alembic migrations, Square payment integration, SMTP2GO email integration, event/RSVP endpoints, configurable profile questions, privacy/terms pages, feature flags, and a fast test gate in `restart.sh`.
|
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
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ The app now includes a FastAPI backend, React/Vite frontend, Docker Compose deve
|
|||||||
- **Membership Tier Management**: Configure different membership levels and associated fees
|
- **Membership Tier Management**: Configure different membership levels and associated fees
|
||||||
- **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions
|
- **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions
|
||||||
- **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance
|
- **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance
|
||||||
|
- **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
|
- **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
|
- **Feature Flags**: View backend feature flags and reload them from the super-admin interface
|
||||||
- **Reporting**: Planned reports on membership statistics and payment status
|
- **Reporting**: Planned reports on membership statistics and payment status
|
||||||
|
|||||||
+11
-2
@@ -30,10 +30,10 @@ membership/
|
|||||||
│ │ ├── email_templates.py
|
│ │ ├── email_templates.py
|
||||||
│ │ ├── events.py # Events and RSVPs
|
│ │ ├── events.py # Events and RSVPs
|
||||||
│ │ └── feature_flags.py
|
│ │ └── feature_flags.py
|
||||||
│ ├── core/ # Config, database, security, default data
|
│ ├── core/ # Config, database, security, datetime helpers, default data
|
||||||
│ ├── models/ # SQLAlchemy models
|
│ ├── models/ # SQLAlchemy models
|
||||||
│ ├── schemas/ # Pydantic schemas
|
│ ├── schemas/ # Pydantic schemas
|
||||||
│ ├── services/ # Email, bounce, Square, feature flags
|
│ ├── services/ # Email, bounce, Square, attendance, feature flags
|
||||||
│ └── tests/ # Fast backend pytest unit tests
|
│ └── tests/ # Fast backend pytest unit tests
|
||||||
│
|
│
|
||||||
├── docker/
|
├── docker/
|
||||||
@@ -64,9 +64,11 @@ membership/
|
|||||||
- **`backend/app/core/config.py`** - Settings management.
|
- **`backend/app/core/config.py`** - Settings management.
|
||||||
- **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions.
|
- **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions.
|
||||||
- **`backend/app/core/security.py`** - JWT tokens and password hashing.
|
- **`backend/app/core/security.py`** - JWT tokens and password hashing.
|
||||||
|
- **`backend/app/core/datetime.py`** - UTC helpers and Zulu serialization helpers.
|
||||||
- **`backend/app/models/models.py`** - Database tables.
|
- **`backend/app/models/models.py`** - Database tables.
|
||||||
- **`backend/app/schemas/schemas.py`** - API request/response models.
|
- **`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_profile_question_logic.py`** - Fast backend unit tests for profile answer validation.
|
||||||
|
- **`backend/app/tests/test_datetime_utc.py`** - UTC normalization and serialization tests.
|
||||||
|
|
||||||
### Frontend Application
|
### Frontend Application
|
||||||
- **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard.
|
- **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard.
|
||||||
@@ -76,7 +78,9 @@ membership/
|
|||||||
- **`frontend/src/components/ProfileQuestionsForm.tsx`** - Member/admin answer form with dependency handling.
|
- **`frontend/src/components/ProfileQuestionsForm.tsx`** - Member/admin answer form with dependency handling.
|
||||||
- **`frontend/src/components/EmailTemplateManagement.tsx`** - Email template editing.
|
- **`frontend/src/components/EmailTemplateManagement.tsx`** - Email template editing.
|
||||||
- **`frontend/src/components/BounceManagement.tsx`** - SMTP2GO bounce management.
|
- **`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/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
|
## API Endpoints
|
||||||
|
|
||||||
@@ -87,6 +91,7 @@ membership/
|
|||||||
- **`payments.py`** - Payment history, manual payments, Square config/process/refund.
|
- **`payments.py`** - Payment history, manual payments, Square config/process/refund.
|
||||||
- **`events.py`** - Event CRUD, upcoming events, RSVP create/update, RSVP listing.
|
- **`events.py`** - Event CRUD, upcoming events, RSVP create/update, RSVP listing.
|
||||||
- **`email.py`** - SMTP2GO test emails, welcome email tests, bounce webhook, bounce stats, cleanup, deactivation.
|
- **`email.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.
|
- **`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.
|
- **`feature_flags.py`** - Public feature flag listing/lookup and super-admin-only reload.
|
||||||
|
|
||||||
@@ -104,6 +109,10 @@ Fully implemented:
|
|||||||
- **EmailTemplate** - Editable database-backed email templates.
|
- **EmailTemplate** - Editable database-backed email templates.
|
||||||
- **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking.
|
- **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking.
|
||||||
- **PasswordResetToken** - One-time password reset support.
|
- **PasswordResetToken** - One-time password reset support.
|
||||||
|
- **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.
|
- **VolunteerRole** - Volunteer role definitions.
|
||||||
- **VolunteerAssignment** - Member-to-role assignments.
|
- **VolunteerAssignment** - Member-to-role assignments.
|
||||||
- **VolunteerSchedule** - Volunteer shift schedules.
|
- **VolunteerSchedule** - Volunteer shift schedules.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ 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`.
|
||||||
@@ -137,6 +139,12 @@ docker compose logs -f gateway
|
|||||||
2. Members can view upcoming events and submit RSVP status
|
2. Members can view upcoming events and submit RSVP status
|
||||||
3. Admins can view RSVP lists and attendance data
|
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
|
### Manage email templates and bounces
|
||||||
1. Super admins can edit database-backed email templates; previews are shown as escaped HTML text
|
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
|
2. SMTP2GO bounce webhooks are stored and visible in bounce management
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ A membership management system for Swansea Airport Stakeholders' Alliance, built
|
|||||||
- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits.
|
- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits.
|
||||||
- **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking.
|
- **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking.
|
||||||
- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility.
|
- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility.
|
||||||
|
- **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.
|
- **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.
|
- **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.
|
- **Feature flags**: Backend feature-flag service with frontend context and admin status/reload controls.
|
||||||
@@ -49,11 +51,13 @@ membership/
|
|||||||
│ │ │ │ ├── email.py # SMTP2GO email and bounces
|
│ │ │ │ ├── email.py # SMTP2GO email and bounces
|
||||||
│ │ │ │ ├── email_templates.py
|
│ │ │ │ ├── email_templates.py
|
||||||
│ │ │ │ ├── events.py # Events and RSVPs
|
│ │ │ │ ├── events.py # Events and RSVPs
|
||||||
|
│ │ │ │ ├── esp.py # ESP RFID provisioning, taps, attendance, write jobs
|
||||||
│ │ │ │ └── feature_flags.py
|
│ │ │ │ └── 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
|
||||||
@@ -64,11 +68,11 @@ membership/
|
|||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # Dashboard, payment, admin, profile components
|
│ │ ├── components/ # Dashboard, payment, admin, profile, ESP components
|
||||||
│ │ ├── contexts/ # Feature flag context
|
│ │ ├── contexts/ # Feature flag, toast, and confirm contexts
|
||||||
│ │ ├── pages/ # Login, register, dashboard, policy pages
|
│ │ ├── pages/ # Login, register, dashboard, policy pages
|
||||||
│ │ ├── services/ # API clients
|
│ │ ├── services/ # API clients
|
||||||
│ │ └── utils/ # Tested frontend logic
|
│ │ └── utils/ # Tested frontend logic and timezone helpers
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -261,6 +265,38 @@ docker compose --profile prod down
|
|||||||
- `GET /api/v1/feature-flags/flags` - List flags
|
- `GET /api/v1/feature-flags/flags` - List flags
|
||||||
- `POST /api/v1/feature-flags/flags/reload` - Reload flags (super admin)
|
- `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
|
||||||
|
|
||||||
### Basic Operations
|
### Basic Operations
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags
|
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags, esp
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -12,3 +12,4 @@ api_router.include_router(email.router, prefix="/email", tags=["email"])
|
|||||||
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
||||||
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
||||||
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
|
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
|
||||||
|
api_router.include_router(esp.router, prefix="/esp", tags=["esp-rfid"])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import List
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
|
from ...core.datetime import utc_now
|
||||||
from ...core.security import verify_password, get_password_hash, create_access_token
|
from ...core.security import verify_password, get_password_hash, create_access_token
|
||||||
from ...models.models import User, UserRole, PasswordResetToken
|
from ...models.models import User, UserRole, PasswordResetToken
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
@@ -85,7 +86,7 @@ async def login(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
user.last_login = datetime.utcnow()
|
user.last_login = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Create access token
|
# Create access token
|
||||||
@@ -120,7 +121,7 @@ async def login_json(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
user.last_login = datetime.utcnow()
|
user.last_login = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Create access token
|
# Create access token
|
||||||
@@ -149,12 +150,12 @@ async def forgot_password(
|
|||||||
db.query(PasswordResetToken).filter(
|
db.query(PasswordResetToken).filter(
|
||||||
PasswordResetToken.user_id == user.id,
|
PasswordResetToken.user_id == user.id,
|
||||||
PasswordResetToken.used == False,
|
PasswordResetToken.used == False,
|
||||||
PasswordResetToken.expires_at > datetime.utcnow()
|
PasswordResetToken.expires_at > utc_now()
|
||||||
).update({"used": True})
|
).update({"used": True})
|
||||||
|
|
||||||
# Generate new reset token
|
# Generate new reset token
|
||||||
reset_token = str(uuid.uuid4())
|
reset_token = str(uuid.uuid4())
|
||||||
expires_at = datetime.utcnow() + timedelta(hours=1) # Token expires in 1 hour
|
expires_at = utc_now() + timedelta(hours=1) # Token expires in 1 hour
|
||||||
|
|
||||||
# Create password reset token
|
# Create password reset token
|
||||||
db_token = PasswordResetToken(
|
db_token = PasswordResetToken(
|
||||||
@@ -192,7 +193,7 @@ async def reset_password(
|
|||||||
reset_token = db.query(PasswordResetToken).filter(
|
reset_token = db.query(PasswordResetToken).filter(
|
||||||
PasswordResetToken.token == request.token,
|
PasswordResetToken.token == request.token,
|
||||||
PasswordResetToken.used == False,
|
PasswordResetToken.used == False,
|
||||||
PasswordResetToken.expires_at > datetime.utcnow()
|
PasswordResetToken.expires_at > utc_now()
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not reset_token:
|
if not reset_token:
|
||||||
@@ -212,7 +213,7 @@ async def reset_password(
|
|||||||
# Update password
|
# Update password
|
||||||
hashed_password = get_password_hash(request.new_password)
|
hashed_password = get_password_hash(request.new_password)
|
||||||
user.hashed_password = hashed_password
|
user.hashed_password = hashed_password
|
||||||
user.updated_at = datetime.utcnow()
|
user.updated_at = utc_now()
|
||||||
|
|
||||||
# Mark token as used
|
# Mark token as used
|
||||||
reset_token.used = True
|
reset_token.used = True
|
||||||
@@ -239,7 +240,7 @@ async def change_password(
|
|||||||
# Update password
|
# Update password
|
||||||
hashed_password = get_password_hash(request.new_password)
|
hashed_password = get_password_hash(request.new_password)
|
||||||
current_user.hashed_password = hashed_password
|
current_user.hashed_password = hashed_password
|
||||||
current_user.updated_at = datetime.utcnow()
|
current_user.updated_at = utc_now()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from ...api.dependencies import get_admin_user
|
|||||||
from ...models.models import User
|
from ...models.models import User
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
|
from ...core.datetime import to_zulu_iso
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -95,7 +96,7 @@ async def get_bounce_list(
|
|||||||
"email": bounce.email,
|
"email": bounce.email,
|
||||||
"bounce_type": bounce.bounce_type.value,
|
"bounce_type": bounce.bounce_type.value,
|
||||||
"bounce_reason": bounce.bounce_reason,
|
"bounce_reason": bounce.bounce_reason,
|
||||||
"bounce_date": bounce.bounce_date.isoformat(),
|
"bounce_date": to_zulu_iso(bounce.bounce_date),
|
||||||
"is_active": bounce.is_active,
|
"is_active": bounce.is_active,
|
||||||
"smtp2go_message_id": bounce.smtp2go_message_id
|
"smtp2go_message_id": bounce.smtp2go_message_id
|
||||||
}
|
}
|
||||||
@@ -132,7 +133,7 @@ async def get_bounce_history(
|
|||||||
"id": bounce.id,
|
"id": bounce.id,
|
||||||
"bounce_type": bounce.bounce_type.value,
|
"bounce_type": bounce.bounce_type.value,
|
||||||
"bounce_reason": bounce.bounce_reason,
|
"bounce_reason": bounce.bounce_reason,
|
||||||
"bounce_date": bounce.bounce_date.isoformat(),
|
"bounce_date": to_zulu_iso(bounce.bounce_date),
|
||||||
"is_active": bounce.is_active,
|
"is_active": bounce.is_active,
|
||||||
"smtp2go_message_id": bounce.smtp2go_message_id
|
"smtp2go_message_id": bounce.smtp2go_message_id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
|
from ...core.datetime import utc_now
|
||||||
from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
|
from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
@@ -691,11 +692,11 @@ async def send_user_password_reset(
|
|||||||
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})
|
||||||
|
|
||||||
reset_token = str(uuid.uuid4())
|
reset_token = str(uuid.uuid4())
|
||||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
expires_at = utc_now() + timedelta(hours=1)
|
||||||
|
|
||||||
db_token = PasswordResetToken(
|
db_token = PasswordResetToken(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
+35
-1
@@ -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,12 +32,19 @@ 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)
|
||||||
|
attendance_task.cancel()
|
||||||
|
try:
|
||||||
|
await attendance_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+175
-33
@@ -3,9 +3,9 @@ from sqlalchemy import (
|
|||||||
Float, Text, ForeignKey, Date, UniqueConstraint
|
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"
|
||||||
|
|
||||||
@@ -62,8 +93,8 @@ class User(Base):
|
|||||||
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)
|
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
|
||||||
@@ -78,6 +109,8 @@ class User(Base):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
foreign_keys="UserProfileAnswer.user_id"
|
foreign_keys="UserProfileAnswer.user_id"
|
||||||
)
|
)
|
||||||
|
rfid_cards = relationship("RfidCard", back_populates="user")
|
||||||
|
attendance_sessions = relationship("AttendanceSession", back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
class ProfileQuestion(Base):
|
class ProfileQuestion(Base):
|
||||||
@@ -96,8 +129,8 @@ class ProfileQuestion(Base):
|
|||||||
display_order = Column(Integer, default=0, nullable=False)
|
display_order = Column(Integer, default=0, nullable=False)
|
||||||
depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True)
|
depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True)
|
||||||
depends_on_value = Column(String(255), nullable=True)
|
depends_on_value = Column(String(255), 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)
|
||||||
|
|
||||||
depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions")
|
depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions")
|
||||||
answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan")
|
answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan")
|
||||||
@@ -114,8 +147,8 @@ class UserProfileAnswer(Base):
|
|||||||
question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True)
|
question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True)
|
||||||
value_text = Column(Text, nullable=True)
|
value_text = Column(Text, nullable=True)
|
||||||
updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
updated_by_user_id = Column(Integer, ForeignKey("users.id"), 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)
|
||||||
|
|
||||||
user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers")
|
user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers")
|
||||||
question = relationship("ProfileQuestion", back_populates="answers")
|
question = relationship("ProfileQuestion", back_populates="answers")
|
||||||
@@ -131,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")
|
||||||
@@ -148,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")
|
||||||
@@ -169,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")
|
||||||
@@ -189,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")
|
||||||
@@ -205,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"
|
||||||
|
|
||||||
@@ -220,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")
|
||||||
@@ -236,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")
|
||||||
@@ -256,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")
|
||||||
@@ -275,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")
|
||||||
@@ -294,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):
|
||||||
@@ -308,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):
|
||||||
@@ -319,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")
|
||||||
@@ -336,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):
|
||||||
@@ -357,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)
|
||||||
|
|||||||
@@ -44,6 +44,29 @@ from .schemas import (
|
|||||||
ProfileQuestionForUser,
|
ProfileQuestionForUser,
|
||||||
ProfileAnswerUpdate,
|
ProfileAnswerUpdate,
|
||||||
ProfileAnswersUpdateRequest,
|
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__ = [
|
||||||
@@ -92,4 +115,27 @@ __all__ = [
|
|||||||
"ProfileQuestionForUser",
|
"ProfileQuestionForUser",
|
||||||
"ProfileAnswerUpdate",
|
"ProfileAnswerUpdate",
|
||||||
"ProfileAnswersUpdateRequest",
|
"ProfileAnswersUpdateRequest",
|
||||||
|
"EspReaderCreate",
|
||||||
|
"EspReaderUpdate",
|
||||||
|
"EspReaderResponse",
|
||||||
|
"EspReaderCreateResponse",
|
||||||
|
"EspReaderRegistrationRequest",
|
||||||
|
"EspReaderRegistrationResponse",
|
||||||
|
"EspReaderProvisioningResponse",
|
||||||
|
"RfidCardCreate",
|
||||||
|
"RfidCardUpdate",
|
||||||
|
"RfidCardResponse",
|
||||||
|
"RfidTapRequest",
|
||||||
|
"RfidTapResponse",
|
||||||
|
"RfidWriteJobCreate",
|
||||||
|
"RfidWriteJobCompleteRequest",
|
||||||
|
"RfidWriteJobResponse",
|
||||||
|
"EspTimeResponse",
|
||||||
|
"EspHeartbeatRequest",
|
||||||
|
"EspHeartbeatResponse",
|
||||||
|
"EspDashboardLoginResponse",
|
||||||
|
"RfidTapAdminResponse",
|
||||||
|
"AttendanceSessionResponse",
|
||||||
|
"StaleSessionCloseRequest",
|
||||||
|
"StaleSessionCloseResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
+283
-33
@@ -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, Literal, Any
|
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,7 +49,7 @@ 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)
|
||||||
@@ -43,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)
|
||||||
@@ -84,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)
|
||||||
@@ -101,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
|
||||||
|
|
||||||
@@ -111,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
|
||||||
@@ -133,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
|
||||||
@@ -143,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
|
||||||
@@ -166,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")
|
||||||
@@ -176,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
|
||||||
@@ -189,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)")
|
||||||
@@ -197,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
|
||||||
@@ -216,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
|
||||||
@@ -235,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
|
||||||
@@ -248,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
|
||||||
@@ -269,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
|
||||||
|
|
||||||
@@ -293,12 +325,12 @@ class EventRSVPResponse(EventRSVPBase):
|
|||||||
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
|
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
|
||||||
|
|
||||||
|
|
||||||
class QuestionOption(BaseModel):
|
class QuestionOption(UTCBaseModel):
|
||||||
label: str = Field(..., min_length=1, max_length=100)
|
label: str = Field(..., min_length=1, max_length=100)
|
||||||
value: str = Field(..., min_length=1, max_length=100)
|
value: str = Field(..., min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class ProfileQuestionBase(BaseModel):
|
class ProfileQuestionBase(UTCBaseModel):
|
||||||
key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
|
key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
|
||||||
label: str = Field(..., min_length=2, max_length=255)
|
label: str = Field(..., min_length=2, max_length=255)
|
||||||
help_text: Optional[str] = None
|
help_text: Optional[str] = None
|
||||||
@@ -317,7 +349,7 @@ class ProfileQuestionCreate(ProfileQuestionBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ProfileQuestionUpdate(BaseModel):
|
class ProfileQuestionUpdate(UTCBaseModel):
|
||||||
key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
|
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)
|
label: Optional[str] = Field(None, min_length=2, max_length=255)
|
||||||
help_text: Optional[str] = None
|
help_text: Optional[str] = None
|
||||||
@@ -332,7 +364,7 @@ class ProfileQuestionUpdate(BaseModel):
|
|||||||
depends_on_value: Optional[str] = Field(None, max_length=255)
|
depends_on_value: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class ProfileQuestionResponse(BaseModel):
|
class ProfileQuestionResponse(UTCBaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
@@ -357,10 +389,228 @@ class ProfileQuestionForUser(ProfileQuestionResponse):
|
|||||||
can_edit: bool = True
|
can_edit: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ProfileAnswerUpdate(BaseModel):
|
class ProfileAnswerUpdate(UTCBaseModel):
|
||||||
question_id: int
|
question_id: int
|
||||||
value: Optional[Any] = None
|
value: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileAnswersUpdateRequest(BaseModel):
|
class ProfileAnswersUpdateRequest(UTCBaseModel):
|
||||||
answers: list[ProfileAnswerUpdate]
|
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)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from ..core.datetime import to_utc_naive, utc_now
|
||||||
from ..models.models import EmailBounce, BounceType
|
from ..models.models import EmailBounce, BounceType
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
|
|
||||||
@@ -38,7 +39,9 @@ class BounceService:
|
|||||||
db = next(get_db())
|
db = next(get_db())
|
||||||
|
|
||||||
if bounce_date is None:
|
if bounce_date is None:
|
||||||
bounce_date = datetime.utcnow()
|
bounce_date = utc_now()
|
||||||
|
else:
|
||||||
|
bounce_date = to_utc_naive(bounce_date)
|
||||||
|
|
||||||
# Check if bounce already exists for this email and type
|
# Check if bounce already exists for this email and type
|
||||||
existing_bounce = db.query(EmailBounce).filter(
|
existing_bounce = db.query(EmailBounce).filter(
|
||||||
@@ -54,7 +57,7 @@ class BounceService:
|
|||||||
if smtp2go_message_id:
|
if smtp2go_message_id:
|
||||||
existing_bounce.smtp2go_message_id = smtp2go_message_id
|
existing_bounce.smtp2go_message_id = smtp2go_message_id
|
||||||
existing_bounce.bounce_date = bounce_date
|
existing_bounce.bounce_date = bounce_date
|
||||||
existing_bounce.updated_at = datetime.utcnow()
|
existing_bounce.updated_at = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(existing_bounce)
|
db.refresh(existing_bounce)
|
||||||
return existing_bounce
|
return existing_bounce
|
||||||
@@ -130,7 +133,7 @@ class BounceService:
|
|||||||
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
|
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
|
||||||
if bounce:
|
if bounce:
|
||||||
bounce.is_active = False
|
bounce.is_active = False
|
||||||
bounce.updated_at = datetime.utcnow()
|
bounce.updated_at = utc_now()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -189,9 +192,10 @@ class BounceService:
|
|||||||
try:
|
try:
|
||||||
# SMTP2GO timestamps are typically Unix timestamps
|
# SMTP2GO timestamps are typically Unix timestamps
|
||||||
if isinstance(timestamp, (int, float)):
|
if isinstance(timestamp, (int, float)):
|
||||||
bounce_date = datetime.fromtimestamp(timestamp)
|
bounce_date = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||||
elif isinstance(timestamp, str):
|
elif isinstance(timestamp, str):
|
||||||
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
||||||
|
bounce_date = to_utc_naive(bounce_date)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -252,14 +256,14 @@ class BounceService:
|
|||||||
db = next(get_db())
|
db = next(get_db())
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
|
cutoff_date = utc_now() - timedelta(days=days_old)
|
||||||
|
|
||||||
# Only deactivate soft bounces, keep hard bounces and complaints active
|
# Only deactivate soft bounces, keep hard bounces and complaints active
|
||||||
result = db.query(EmailBounce).filter(
|
result = db.query(EmailBounce).filter(
|
||||||
EmailBounce.bounce_type == BounceType.SOFT,
|
EmailBounce.bounce_type == BounceType.SOFT,
|
||||||
EmailBounce.is_active == True,
|
EmailBounce.is_active == True,
|
||||||
EmailBounce.bounce_date < cutoff_date
|
EmailBounce.bounce_date < cutoff_date
|
||||||
).update({'is_active': False, 'updated_at': datetime.utcnow()})
|
).update({'is_active': False, 'updated_at': utc_now()})
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import httpx
|
|||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
|
from ..core.datetime import utc_now
|
||||||
from ..models.models import EmailTemplate
|
from ..models.models import EmailTemplate
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..core.config import settings
|
from ..core.config import settings
|
||||||
@@ -147,7 +148,7 @@ class EmailService:
|
|||||||
"payment_amount": f"£{payment_amount:.2f}",
|
"payment_amount": f"£{payment_amount:.2f}",
|
||||||
"payment_method": payment_method,
|
"payment_method": payment_method,
|
||||||
"renewal_date": renewal_date,
|
"renewal_date": renewal_date,
|
||||||
"payment_date": datetime.now().strftime("%d %B %Y"),
|
"payment_date": utc_now().strftime("%d %B %Y"),
|
||||||
"app_name": settings.APP_NAME
|
"app_name": settings.APP_NAME
|
||||||
}
|
}
|
||||||
return await self.send_templated_email("membership_activation", to_email, variables, db)
|
return await self.send_templated_email("membership_activation", to_email, variables, db)
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
||||||
@@ -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"
|
||||||
@@ -15,7 +15,7 @@ alembic==1.13.0
|
|||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
bcrypt==4.1.1
|
bcrypt==4.0.1
|
||||||
|
|
||||||
# Payment Integration
|
# Payment Integration
|
||||||
squareup==43.2.0.20251016
|
squareup==43.2.0.20251016
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
# mysql:
|
#mysql:
|
||||||
# image: mysql:8.0
|
# image: mysql:8.0
|
||||||
# container_name: membership_mysql
|
# container_name: membership_mysql
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
@@ -13,8 +13,6 @@ services:
|
|||||||
# - "3306"
|
# - "3306"
|
||||||
# volumes:
|
# volumes:
|
||||||
# - mysql_data:/var/lib/mysql
|
# - mysql_data:/var/lib/mysql
|
||||||
# networks:
|
|
||||||
# - membership_private
|
|
||||||
# healthcheck:
|
# healthcheck:
|
||||||
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
# start_period: 10s
|
# start_period: 10s
|
||||||
|
|||||||
Generated
+3411
File diff suppressed because it is too large
Load Diff
+3412
-256
File diff suppressed because it is too large
Load Diff
+16
-19
@@ -1,6 +1,8 @@
|
|||||||
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';
|
||||||
@@ -8,9 +10,10 @@ import ResetPassword from './pages/ResetPassword';
|
|||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||||
import TermsOfService from './pages/TermsOfService';
|
import TermsOfService from './pages/TermsOfService';
|
||||||
|
import AppFooter from './components/layout/AppFooter';
|
||||||
|
import CookieBanner from './components/layout/CookieBanner';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [cookieDismissed, setCookieDismissed] = useState(
|
const [cookieDismissed, setCookieDismissed] = useState(
|
||||||
@@ -25,6 +28,8 @@ const App: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FeatureFlagProvider>
|
<FeatureFlagProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<ToastProvider>
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -34,31 +39,23 @@ const App: React.FC = () => {
|
|||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/email-templates" element={<Navigate to="/dashboard" />} />
|
<Route path="/dashboard/:tab" element={<Dashboard />} />
|
||||||
<Route path="/membership-tiers" element={<Navigate to="/dashboard" />} />
|
<Route path="/dashboard/admin/:section" element={<Dashboard />} />
|
||||||
<Route path="/bounce-management" element={<Navigate to="/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="/privacy-policy" element={<PrivacyPolicy />} />
|
||||||
<Route path="/terms-of-service" element={<TermsOfService />} />
|
<Route path="/terms-of-service" element={<TermsOfService />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<footer className="site-footer">
|
<AppFooter />
|
||||||
<div>
|
|
||||||
<Link to="/privacy-policy">Privacy Policy</Link>
|
|
||||||
<Link to="/terms-of-service">Terms of Service</Link>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '8px' }}>SASA Portal</div>
|
|
||||||
</footer>
|
|
||||||
{!cookieDismissed && (
|
{!cookieDismissed && (
|
||||||
<div className="cookie-banner">
|
<CookieBanner onDismiss={dismissCookies} />
|
||||||
<div>
|
|
||||||
We use cookies for session authentication, security, and basic site functionality.
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary" style={{ padding: '6px 12px' }} onClick={dismissCookies}>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ import {
|
|||||||
ProfileQuestionUpsertData,
|
ProfileQuestionUpsertData,
|
||||||
userService
|
userService
|
||||||
} from '../services/membershipService';
|
} from '../services/membershipService';
|
||||||
|
import { useConfirm } from '../contexts/ConfirmContext';
|
||||||
|
|
||||||
interface AdminProfileQuestionManagerProps {
|
interface AdminProfileQuestionManagerProps {
|
||||||
onQuestionsChanged?: () => void;
|
onQuestionsChanged?: () => void;
|
||||||
|
openEditorToken?: number;
|
||||||
|
searchTerm?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuestionSortKey = 'order' | 'label' | 'type' | 'key' | 'status';
|
||||||
|
|
||||||
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
|
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
|
||||||
|
|
||||||
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
|
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
|
||||||
@@ -34,13 +39,22 @@ const textToOptions = (value: string): ProfileQuestionOption[] => {
|
|||||||
.filter((option) => option.label.length > 0 && option.value.length > 0);
|
.filter((option) => option.label.length > 0 && option.value.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({ onQuestionsChanged }) => {
|
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({
|
||||||
|
onQuestionsChanged,
|
||||||
|
openEditorToken = 0,
|
||||||
|
searchTerm = ''
|
||||||
|
}) => {
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
|
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
|
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
|
||||||
const [listSearch, setListSearch] = useState('');
|
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 = {
|
const emptyForm: ProfileQuestionUpsertData = {
|
||||||
key: '',
|
key: '',
|
||||||
@@ -77,9 +91,19 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
loadQuestions();
|
loadQuestions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dependencyCandidates = useMemo(() => {
|
useEffect(() => {
|
||||||
return questions.filter((question) => question.id !== editingQuestionId);
|
if (openEditorToken > 0) {
|
||||||
}, [questions, editingQuestionId]);
|
setFormData(emptyForm);
|
||||||
|
setOptionsText('');
|
||||||
|
setEditingQuestionId(null);
|
||||||
|
setIsEditorOpen(true);
|
||||||
|
}
|
||||||
|
}, [openEditorToken]);
|
||||||
|
|
||||||
|
const dependencyCandidates = useMemo(
|
||||||
|
() => questions.filter((question) => question.id !== editingQuestionId),
|
||||||
|
[questions, editingQuestionId]
|
||||||
|
);
|
||||||
|
|
||||||
const selectedDependencyQuestion = useMemo(() => {
|
const selectedDependencyQuestion = useMemo(() => {
|
||||||
if (!formData.depends_on_question_id) {
|
if (!formData.depends_on_question_id) {
|
||||||
@@ -89,7 +113,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
}, [questions, formData.depends_on_question_id]);
|
}, [questions, formData.depends_on_question_id]);
|
||||||
|
|
||||||
const filteredQuestions = useMemo(() => {
|
const filteredQuestions = useMemo(() => {
|
||||||
const term = listSearch.trim().toLowerCase();
|
const term = searchTerm.trim().toLowerCase();
|
||||||
if (!term) {
|
if (!term) {
|
||||||
return questions;
|
return questions;
|
||||||
}
|
}
|
||||||
@@ -97,7 +121,60 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
question.label.toLowerCase().includes(term) ||
|
question.label.toLowerCase().includes(term) ||
|
||||||
question.key.toLowerCase().includes(term)
|
question.key.toLowerCase().includes(term)
|
||||||
);
|
);
|
||||||
}, [questions, listSearch]);
|
}, [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 = () => {
|
const resetForm = () => {
|
||||||
setFormData(emptyForm);
|
setFormData(emptyForm);
|
||||||
@@ -105,6 +182,11 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
setEditingQuestionId(null);
|
setEditingQuestionId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
resetForm();
|
||||||
|
setIsEditorOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (question: ProfileQuestion) => {
|
const handleEdit = (question: ProfileQuestion) => {
|
||||||
setEditingQuestionId(question.id);
|
setEditingQuestionId(question.id);
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -122,6 +204,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
depends_on_value: question.depends_on_value
|
depends_on_value: question.depends_on_value
|
||||||
});
|
});
|
||||||
setOptionsText(optionsToText(question.options));
|
setOptionsText(optionsToText(question.options));
|
||||||
|
setIsEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -146,7 +229,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadQuestions();
|
await loadQuestions();
|
||||||
resetForm();
|
closeEditor();
|
||||||
onQuestionsChanged?.();
|
onQuestionsChanged?.();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || err.message || 'Failed to save question');
|
setError(err.response?.data?.detail || err.message || 'Failed to save question');
|
||||||
@@ -156,7 +239,13 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivate = async (questionId: number) => {
|
const handleDeactivate = async (questionId: number) => {
|
||||||
if (!window.confirm('Deactivate this question? Existing answers are kept.')) {
|
const confirmed = await confirm({
|
||||||
|
title: 'Deactivate question',
|
||||||
|
message: 'Deactivate this question? Existing answers are kept.',
|
||||||
|
confirmLabel: 'Deactivate',
|
||||||
|
tone: 'danger'
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,43 +258,159 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const toggleSort = (nextKey: QuestionSortKey) => {
|
||||||
<div className="card" style={{ marginTop: '20px' }}>
|
if (sortKey === nextKey) {
|
||||||
<h3 style={{ marginBottom: '10px' }}>Profile Questions (Admin)</h3>
|
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
<p style={{ marginBottom: '16px', color: '#555' }}>
|
return;
|
||||||
Manage the set of profile questions users can answer. You can add follow-up questions with dependencies.
|
}
|
||||||
</p>
|
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>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '10px', marginBottom: '20px' }}>
|
{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
|
<input
|
||||||
|
className="admin-field"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Question key (e.g. pilot_license_type)"
|
placeholder="Question key"
|
||||||
value={formData.key}
|
value={formData.key}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
className="admin-field"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Question label"
|
placeholder="Question label"
|
||||||
value={formData.label}
|
value={formData.label}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Help text (optional)"
|
className="admin-field admin-field-textarea"
|
||||||
|
placeholder="Help text"
|
||||||
value={formData.help_text || ''}
|
value={formData.help_text || ''}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
|
||||||
rows={2}
|
rows={2}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
<div className="admin-field-grid">
|
||||||
<select
|
<select
|
||||||
|
className="admin-field"
|
||||||
value={formData.input_type}
|
value={formData.input_type}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
>
|
>
|
||||||
{INPUT_TYPES.map((type) => (
|
{INPUT_TYPES.map((type) => (
|
||||||
<option key={type} value={type}>{type}</option>
|
<option key={type} value={type}>{type}</option>
|
||||||
@@ -213,24 +418,25 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
className="admin-field"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Display order"
|
placeholder="Display order"
|
||||||
value={formData.display_order ?? 0}
|
value={formData.display_order ?? 0}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
className="admin-field"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Placeholder"
|
placeholder="Placeholder"
|
||||||
value={formData.placeholder || ''}
|
value={formData.placeholder || ''}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
<div className="admin-field-grid">
|
||||||
<select
|
<select
|
||||||
|
className="admin-field"
|
||||||
value={formData.depends_on_question_id ?? ''}
|
value={formData.depends_on_question_id ?? ''}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextValue = event.target.value;
|
const nextValue = event.target.value;
|
||||||
@@ -240,7 +446,6 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
depends_on_value: null
|
depends_on_value: null
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
>
|
>
|
||||||
<option value="">No dependency</option>
|
<option value="">No dependency</option>
|
||||||
{dependencyCandidates.map((question) => (
|
{dependencyCandidates.map((question) => (
|
||||||
@@ -250,19 +455,19 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
|
|
||||||
{!selectedDependencyQuestion && (
|
{!selectedDependencyQuestion && (
|
||||||
<input
|
<input
|
||||||
|
className="admin-field admin-field-disabled"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Choose a dependency question first"
|
placeholder="Choose a dependency question first"
|
||||||
value=""
|
value=""
|
||||||
disabled
|
disabled
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f7fa' }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedDependencyQuestion?.input_type === 'select' && (
|
{selectedDependencyQuestion?.input_type === 'select' && (
|
||||||
<select
|
<select
|
||||||
|
className="admin-field"
|
||||||
value={formData.depends_on_value || ''}
|
value={formData.depends_on_value || ''}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
>
|
>
|
||||||
<option value="">Any answered value</option>
|
<option value="">Any answered value</option>
|
||||||
{selectedDependencyQuestion.options.map((option) => (
|
{selectedDependencyQuestion.options.map((option) => (
|
||||||
@@ -273,9 +478,9 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
|
|
||||||
{selectedDependencyQuestion?.input_type === 'boolean' && (
|
{selectedDependencyQuestion?.input_type === 'boolean' && (
|
||||||
<select
|
<select
|
||||||
|
className="admin-field"
|
||||||
value={formData.depends_on_value || ''}
|
value={formData.depends_on_value || ''}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
>
|
>
|
||||||
<option value="">Any answered value</option>
|
<option value="">Any answered value</option>
|
||||||
<option value="true">Yes</option>
|
<option value="true">Yes</option>
|
||||||
@@ -285,122 +490,43 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
|||||||
|
|
||||||
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
|
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
|
||||||
<input
|
<input
|
||||||
|
className="admin-field"
|
||||||
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
|
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
|
||||||
placeholder="Show when parent answer equals..."
|
placeholder="Show when parent answer equals..."
|
||||||
value={formData.depends_on_value || ''}
|
value={formData.depends_on_value || ''}
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.input_type === 'select' && (
|
{formData.input_type === 'select' && (
|
||||||
<textarea
|
<textarea
|
||||||
|
className="admin-field admin-field-textarea"
|
||||||
value={optionsText}
|
value={optionsText}
|
||||||
onChange={(event) => setOptionsText(event.target.value)}
|
onChange={(event) => setOptionsText(event.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
|
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
|
||||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
<div className="admin-switch-group admin-question-switches">
|
||||||
<label>
|
<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>
|
||||||
<input
|
<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>
|
||||||
type="checkbox"
|
<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>
|
||||||
checked={Boolean(formData.is_required)}
|
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))}
|
|
||||||
style={{ marginRight: '6px' }}
|
|
||||||
/>
|
|
||||||
Required
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(formData.admin_only_edit)}
|
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))}
|
|
||||||
style={{ marginRight: '6px' }}
|
|
||||||
/>
|
|
||||||
Admin-only edits
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(formData.is_active)}
|
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))}
|
|
||||||
style={{ marginRight: '6px' }}
|
|
||||||
/>
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '10px' }}>
|
<div className="admin-form-actions">
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
|
||||||
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
|
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
|
||||||
</button>
|
</button>
|
||||||
{editingQuestionId && (
|
<button className="btn btn-secondary" onClick={closeEditor}>
|
||||||
<button className="btn btn-secondary" onClick={resetForm}>
|
Cancel
|
||||||
Cancel Edit
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<h4 style={{ marginBottom: '10px' }}>Existing Questions</h4>
|
</div>
|
||||||
<input
|
</aside>
|
||||||
type="text"
|
|
||||||
placeholder="Search by label or key..."
|
|
||||||
value={listSearch}
|
|
||||||
onChange={(event) => setListSearch(event.target.value)}
|
|
||||||
style={{ marginBottom: '10px', width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
||||||
/>
|
|
||||||
{loading ? (
|
|
||||||
<p>Loading questions...</p>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
|
||||||
<th style={{ padding: '8px', textAlign: 'left' }}>Order</th>
|
|
||||||
<th style={{ padding: '8px', textAlign: 'left' }}>Label</th>
|
|
||||||
<th style={{ padding: '8px', textAlign: 'left' }}>Type</th>
|
|
||||||
<th style={{ padding: '8px', textAlign: 'left' }}>Key</th>
|
|
||||||
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
|
|
||||||
<th style={{ padding: '8px', textAlign: 'left' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredQuestions.map((question) => (
|
|
||||||
<tr key={question.id} style={{ borderBottom: '1px solid #eee' }}>
|
|
||||||
<td style={{ padding: '8px' }}>{question.display_order}</td>
|
|
||||||
<td style={{ padding: '8px' }}>
|
|
||||||
{question.label}
|
|
||||||
{question.admin_only_edit && (
|
|
||||||
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
|
|
||||||
Admin Managed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '8px' }}>{question.input_type}</td>
|
|
||||||
<td style={{ padding: '8px' }}>{question.key}</td>
|
|
||||||
<td style={{ padding: '8px' }}>{question.is_active ? 'Active' : 'Inactive'}</td>
|
|
||||||
<td style={{ padding: '8px' }}>
|
|
||||||
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 8px', marginRight: '6px' }} onClick={() => handleEdit(question)}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{question.is_active && (
|
|
||||||
<button className="btn btn-danger" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => handleDeactivate(question.id)}>
|
|
||||||
Deactivate
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filteredQuestions.length === 0 && (
|
|
||||||
<p style={{ padding: '10px', color: '#666' }}>No questions match your search.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
let result = 0;
|
||||||
return new Date(dateString).toLocaleString();
|
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 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 (
|
return <p className="admin-empty">Loading bounce data...</p>;
|
||||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
|
||||||
<div>Loading bounce data...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Statistics Cards */}
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<div style={{
|
<div className="admin-stat-grid">
|
||||||
display: 'grid',
|
<div className="admin-stat-card"><span>Total Bounces</span><strong>{stats.total_bounces}</strong></div>
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
<div className="admin-stat-card attention"><span>Active Bounces</span><strong>{stats.active_bounces}</strong></div>
|
||||||
gap: '20px',
|
<div className="admin-stat-card"><span>Hard Bounces</span><strong>{stats.bounce_types.hard}</strong></div>
|
||||||
marginBottom: '30px'
|
<div className="admin-stat-card"><span>Soft Bounces</span><strong>{stats.bounce_types.soft}</strong></div>
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
padding: '20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #dee2e6'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Total Bounces</h3>
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
|
|
||||||
{stats.total_bounces}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
padding: '20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #dee2e6'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Active Bounces</h3>
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
|
|
||||||
{stats.active_bounces}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
padding: '20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #dee2e6'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Hard Bounces</h3>
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
|
|
||||||
{stats.bounce_types.hard}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
padding: '20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #dee2e6'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Soft Bounces</h3>
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
|
|
||||||
{stats.bounce_types.soft}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
<div className="admin-table-shell">
|
||||||
<div style={{
|
<div className="admin-table-wrap">
|
||||||
display: 'flex',
|
<table className="admin-table">
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '20px',
|
|
||||||
gap: '20px'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
||||||
<label htmlFor="search" style={{ fontWeight: 'bold' }}>Search by Email:</label>
|
|
||||||
<input
|
|
||||||
id="search"
|
|
||||||
type="text"
|
|
||||||
value={searchEmail}
|
|
||||||
onChange={(e) => setSearchEmail(e.target.value)}
|
|
||||||
placeholder="Enter email address..."
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #ced4da',
|
|
||||||
borderRadius: '4px',
|
|
||||||
minWidth: '250px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCleanupOldBounces}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: '#6c757d',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cleanup Old Bounces
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bounce Records Table */}
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
padding: '20px',
|
|
||||||
borderBottom: '1px solid #dee2e6',
|
|
||||||
backgroundColor: '#f8f9fa'
|
|
||||||
}}>
|
|
||||||
<h2 style={{ margin: 0, color: '#495057' }}>Bounce Records</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table style={{
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse'
|
|
||||||
}}>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: '#f8f9fa' }}>
|
<tr>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Email</th>
|
<th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Type</th>
|
<button type="button" className={sortKey === 'email' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('email')}>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Reason</th>
|
<span>Email</span>{renderSortArrow(sortKey === 'email', sortDirection)}
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Date</th>
|
</button>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Status</th>
|
</th>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Actions</th>
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'type' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('type')}>
|
||||||
|
<span>Type</span>{renderSortArrow(sortKey === 'type', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'reason' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('reason')}>
|
||||||
|
<span>Reason</span>{renderSortArrow(sortKey === 'reason', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'date' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('date')}>
|
||||||
|
<span>Date</span>{renderSortArrow(sortKey === 'date', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
|
||||||
|
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredBounces.length === 0 ? (
|
{paginatedBounces.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} style={{
|
<td colSpan={6} className="admin-table-empty">
|
||||||
padding: '40px',
|
{searchTerm ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
||||||
textAlign: 'center',
|
|
||||||
color: '#6c757d',
|
|
||||||
fontStyle: 'italic'
|
|
||||||
}}>
|
|
||||||
{searchEmail ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredBounces.map((bounce) => (
|
paginatedBounces.map((bounce) => (
|
||||||
<tr key={bounce.id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
<tr key={bounce.id}>
|
||||||
<td style={{ padding: '12px' }}>
|
<td>
|
||||||
<div style={{ fontWeight: '500' }}>{bounce.email}</div>
|
<strong>{bounce.email}</strong>
|
||||||
{bounce.smtp2go_message_id && (
|
{bounce.smtp2go_message_id && <span className="muted-line">ID: {bounce.smtp2go_message_id}</span>}
|
||||||
<div style={{ fontSize: '0.8rem', color: '#6c757d' }}>
|
|
||||||
ID: {bounce.smtp2go_message_id}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td>
|
||||||
<span style={{
|
<span className={`status-badge ${
|
||||||
backgroundColor: getBounceTypeColor(bounce.bounce_type),
|
bounce.bounce_type === 'soft' ? 'status-pending' :
|
||||||
color: 'white',
|
bounce.bounce_type === 'hard' ? 'status-expired' :
|
||||||
padding: '4px 8px',
|
'status-active'
|
||||||
borderRadius: '4px',
|
}`}>
|
||||||
fontSize: '0.8rem',
|
{bounce.bounce_type.toUpperCase()}
|
||||||
fontWeight: 'bold',
|
|
||||||
textTransform: 'uppercase'
|
|
||||||
}}>
|
|
||||||
{bounce.bounce_type}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px', maxWidth: '300px' }}>
|
<td>{bounce.bounce_reason || 'No reason provided'}</td>
|
||||||
<div style={{
|
<td>{formatDate(bounce.bounce_date)}</td>
|
||||||
overflow: 'hidden',
|
<td>
|
||||||
textOverflow: 'ellipsis',
|
<span className={`status-badge ${bounce.is_active ? 'status-expired' : 'status-active'}`}>
|
||||||
whiteSpace: 'nowrap'
|
{bounce.is_active ? 'ACTIVE' : 'RESOLVED'}
|
||||||
}}>
|
|
||||||
{bounce.bounce_reason || 'No reason provided'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px' }}>
|
|
||||||
{formatDate(bounce.bounce_date)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px' }}>
|
|
||||||
<span style={{
|
|
||||||
color: bounce.is_active ? '#dc3545' : '#28a745',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{bounce.is_active ? 'Active' : 'Resolved'}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td>
|
||||||
{bounce.is_active && (
|
{bounce.is_active && (
|
||||||
<button
|
<button className="btn btn-primary" onClick={() => handleDeactivateBounce(bounce.id)}>
|
||||||
onClick={() => handleDeactivateBounce(bounce.id)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
backgroundColor: '#28a745',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.8rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Resolve
|
Resolve
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -341,6 +282,17 @@ const BounceManagement: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-pagination admin-table-footer">
|
||||||
|
<span>Page {currentPage} of {totalPages}</span>
|
||||||
|
<div className="admin-pager-controls">
|
||||||
|
<button className="admin-pager-button" disabled={currentPage <= 1} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))} aria-label="Previous page">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5 6 8l4.5 4.5" /></svg>
|
||||||
|
</button>
|
||||||
|
<button className="admin-pager-button" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} aria-label="Next page">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10 8l-4.5 4.5" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
|
||||||
interface EmailTemplate {
|
interface EmailTemplate {
|
||||||
template_key: string;
|
template_key: string;
|
||||||
@@ -7,22 +8,55 @@ interface EmailTemplate {
|
|||||||
subject: string;
|
subject: string;
|
||||||
html_body: string;
|
html_body: string;
|
||||||
text_body: string;
|
text_body: string;
|
||||||
variables: string; // This comes as JSON string from backend
|
variables: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmailTemplateManagement: React.FC = () => {
|
interface EmailTemplateManagementProps {
|
||||||
|
searchTerm?: string;
|
||||||
|
statusFilter?: 'all' | 'active' | 'inactive';
|
||||||
|
refreshToken?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateSortKey = 'name' | 'key' | 'subject' | 'variables' | 'status';
|
||||||
|
|
||||||
|
const parseTemplateVariables = (variables: string): string[] => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(variables);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return variables
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmailTemplateManagement: React.FC<EmailTemplateManagementProps> = ({
|
||||||
|
searchTerm = '',
|
||||||
|
statusFilter = 'all',
|
||||||
|
refreshToken = 0
|
||||||
|
}) => {
|
||||||
|
const toast = useToast();
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||||
const [showEditForm, setShowEditForm] = useState(false);
|
const [sortKey, setSortKey] = useState<TemplateSortKey>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTemplates();
|
void fetchTemplates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshToken > 0) {
|
||||||
|
void fetchTemplates();
|
||||||
|
}
|
||||||
|
}, [refreshToken]);
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await axios.get('/api/v1/email-templates/', {
|
const response = await axios.get('/api/v1/email-templates/', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
@@ -30,153 +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 style={{ marginBottom: '20px' }}>
|
|
||||||
<button
|
|
||||||
onClick={fetchTemplates}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: '#007bff',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh Templates
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
|
||||||
{templates.map((template) => (
|
|
||||||
<div
|
|
||||||
key={template.template_key}
|
|
||||||
style={{
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '20px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
|
||||||
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{template.name}</h3>
|
|
||||||
<div>
|
<div>
|
||||||
<span style={{
|
<div className="admin-table-shell">
|
||||||
padding: '4px 8px',
|
<div className="admin-table-wrap">
|
||||||
borderRadius: '4px',
|
<table className="admin-table">
|
||||||
fontSize: '12px',
|
<thead>
|
||||||
fontWeight: 'bold',
|
<tr>
|
||||||
backgroundColor: template.is_active ? '#d4edda' : '#f8d7da',
|
<th>
|
||||||
color: template.is_active ? '#155724' : '#721c24'
|
<button type="button" className={sortKey === 'name' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('name')}>
|
||||||
}}>
|
<span>Template</span>{renderSortArrow(sortKey === 'name', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
|
||||||
|
<span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'subject' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('subject')}>
|
||||||
|
<span>Subject</span>{renderSortArrow(sortKey === 'subject', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'variables' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('variables')}>
|
||||||
|
<span>Variables</span>{renderSortArrow(sortKey === 'variables', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
|
||||||
|
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedTemplates.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="admin-table-empty" colSpan={6}>No templates match the current filters.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedTemplates.map((template) => {
|
||||||
|
const variables = parseTemplateVariables(template.variables);
|
||||||
|
return (
|
||||||
|
<tr key={template.template_key} onClick={() => setEditingTemplate(template)}>
|
||||||
|
<td>
|
||||||
|
<strong>{template.name}</strong>
|
||||||
|
<div className="muted-line">{variables.length} variable{variables.length === 1 ? '' : 's'}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{template.template_key}</code>
|
||||||
|
</td>
|
||||||
|
<td>{template.subject}</td>
|
||||||
|
<td>
|
||||||
|
{variables.length > 0 ? (
|
||||||
|
<div className="admin-inline-list">
|
||||||
|
{variables.slice(0, 3).map((variable) => (
|
||||||
|
<span key={variable} className="admin-inline-chip">{variable}</span>
|
||||||
|
))}
|
||||||
|
{variables.length > 3 && <span className="muted-line">+{variables.length - 3} more</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted-line">None</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-badge ${template.is_active ? 'status-active' : 'status-expired'}`}>
|
||||||
{template.is_active ? 'Active' : 'Inactive'}
|
{template.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="table-button-row">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditTemplate(template)}
|
className="btn btn-primary"
|
||||||
style={{
|
type="button"
|
||||||
marginLeft: '10px',
|
onClick={(event) => {
|
||||||
padding: '6px 12px',
|
event.stopPropagation();
|
||||||
backgroundColor: '#28a745',
|
setEditingTemplate(template);
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
<div style={{ marginBottom: '10px' }}>
|
);
|
||||||
<strong style={{ color: '#666' }}>Key:</strong> <code style={{ backgroundColor: '#f8f9fa', padding: '2px 4px', borderRadius: '3px' }}>{template.template_key}</code>
|
})
|
||||||
</div>
|
)}
|
||||||
|
</tbody>
|
||||||
<div style={{ marginBottom: '10px' }}>
|
</table>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div>
|
{editingTemplate && (
|
||||||
<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',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
color: '#333'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{template.html_body.substring(0, 300)}
|
|
||||||
{template.html_body.length > 300 ? '...' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showEditForm && editingTemplate && (
|
|
||||||
<EmailTemplateEditForm
|
<EmailTemplateEditForm
|
||||||
template={editingTemplate}
|
template={editingTemplate}
|
||||||
onSave={handleSaveTemplate}
|
onSave={handleSaveTemplate}
|
||||||
onCancel={handleCancelEdit}
|
onCancel={() => setEditingTemplate(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -190,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,
|
||||||
@@ -206,198 +290,186 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
|
|||||||
is_active: template.is_active
|
is_active: template.is_active
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (field: keyof EmailTemplate, value: any) => {
|
const handleChange = (field: keyof typeof formData, value: any) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dataToSave = {
|
onSave({
|
||||||
template_key: template.template_key,
|
template_key: template.template_key,
|
||||||
...formData,
|
...formData,
|
||||||
variables: JSON.stringify(formData.variables)
|
variables: JSON.stringify(formData.variables)
|
||||||
|
});
|
||||||
};
|
};
|
||||||
onSave(dataToSave);
|
|
||||||
}; return (
|
const previewDocument = useMemo(() => {
|
||||||
<div style={{
|
return `
|
||||||
position: 'fixed',
|
<!doctype html>
|
||||||
top: 0,
|
<html lang="en">
|
||||||
left: 0,
|
<head>
|
||||||
right: 0,
|
<meta charset="utf-8" />
|
||||||
bottom: 0,
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
<style>
|
||||||
display: 'flex',
|
body {
|
||||||
alignItems: 'center',
|
margin: 0;
|
||||||
justifyContent: 'center',
|
padding: 24px;
|
||||||
zIndex: 1000
|
background: #ffffff;
|
||||||
}}>
|
color: #111111;
|
||||||
<div style={{
|
font-family: Arial, sans-serif;
|
||||||
backgroundColor: 'white',
|
}
|
||||||
padding: '20px',
|
</style>
|
||||||
borderRadius: '8px',
|
</head>
|
||||||
width: '90%',
|
<body>
|
||||||
maxWidth: '800px',
|
${formData.html_body}
|
||||||
maxHeight: '90vh',
|
</body>
|
||||||
overflow: 'auto'
|
</html>
|
||||||
}}>
|
`;
|
||||||
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>Edit Email Template: {template.name}</h3>
|
}, [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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<div className="form-group">
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<label>Template Key</label>
|
||||||
Template Key:
|
<input type="text" value={template.template_key} disabled className="admin-field admin-field-disabled" />
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={template.template_key}
|
|
||||||
disabled
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: '#f5f5f5'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<small style={{ color: '#666' }}>Template key cannot be changed</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<label>Name</label>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<input type="text" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} className="admin-field" required />
|
||||||
Name:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<label>Subject</label>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
<input type="text" value={formData.subject} onChange={(e) => handleChange('subject', e.target.value)} className="admin-field" required />
|
||||||
Subject:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.subject}
|
|
||||||
onChange={(e) => handleChange('subject', e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<label>Variables</label>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
|
||||||
Variables (comma-separated):
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.variables.join(', ')}
|
value={formData.variables.join(', ')}
|
||||||
onChange={(e) => handleChange('variables', e.target.value.split(',').map(v => v.trim()))}
|
onChange={(e) => handleChange('variables', e.target.value.split(',').map((v) => v.trim()).filter(Boolean))}
|
||||||
style={{
|
className="admin-field"
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<label>HTML Body</label>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
|
||||||
HTML Body:
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.html_body}
|
value={formData.html_body}
|
||||||
onChange={(e) => handleChange('html_body', e.target.value)}
|
onChange={(e) => handleChange('html_body', e.target.value)}
|
||||||
rows={15}
|
rows={15}
|
||||||
style={{
|
className="admin-field admin-field-textarea admin-code-textarea"
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<label>Text Body</label>
|
||||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
|
||||||
Text Body:
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.text_body}
|
value={formData.text_body}
|
||||||
onChange={(e) => handleChange('text_body', e.target.value)}
|
onChange={(e) => handleChange('text_body', e.target.value)}
|
||||||
rows={10}
|
rows={10}
|
||||||
style={{
|
className="admin-field admin-field-textarea admin-code-textarea"
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="admin-switch-row" style={{ marginBottom: '20px' }}>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<input type="checkbox" checked={formData.is_active} onChange={(e) => handleChange('is_active', e.target.checked)} />
|
||||||
<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
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<div className="table-button-row" style={{ justifyContent: 'flex-end' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
<button type="submit" className="btn btn-primary">Save Changes</button>
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="drawer-section">
|
||||||
|
<div className="drawer-section-header">
|
||||||
|
<div>
|
||||||
|
<h4>Preview</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="email-preview-tabs" role="tablist" aria-label="Email preview mode">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={previewMode === 'rendered' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||||
|
onClick={() => setPreviewMode('rendered')}
|
||||||
|
>
|
||||||
|
Rendered
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={previewMode === 'html' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||||
|
onClick={() => setPreviewMode('html')}
|
||||||
|
>
|
||||||
|
HTML
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={previewMode === 'text' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||||||
|
onClick={() => setPreviewMode('text')}
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewMode === 'rendered' && (
|
||||||
|
<div className="email-preview-frame-shell">
|
||||||
|
<iframe
|
||||||
|
title={`${template.name} preview`}
|
||||||
|
className="email-preview-frame"
|
||||||
|
srcDoc={previewDocument}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewMode === 'html' && (
|
||||||
|
<pre className="email-preview-code">{formData.html_body}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewMode === 'text' && (
|
||||||
|
<pre className="email-preview-code">{formData.text_body}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,29 +5,25 @@ const FeatureFlagStatus: React.FC = () => {
|
|||||||
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>;
|
return <div style={{ fontSize: '14px', color: '#8D96A3' }}>Loading feature flags...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>;
|
return <div style={{ fontSize: '14px', color: '#EE6368' }}>Error loading feature flags</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!flags) {
|
if (!flags) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReload = async () => {
|
|
||||||
try {
|
|
||||||
await reloadFlags();
|
|
||||||
console.log('Feature flags reloaded');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to reload feature flags:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: '20px' }}>
|
<div className="admin-surface" style={{ marginBottom: '20px' }}>
|
||||||
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
|
<div className="admin-surface-header">
|
||||||
|
<div>
|
||||||
|
<h4>Feature Flags Status</h4>
|
||||||
|
<p>Environment-driven switches for admin-controlled behavior.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
||||||
{Object.entries(flags.flags).map(([name, value]) => (
|
{Object.entries(flags.flags).map(([name, value]) => (
|
||||||
@@ -37,23 +33,28 @@ const FeatureFlagStatus: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '8px 12px',
|
padding: '10px 12px',
|
||||||
backgroundColor: '#f5f5f5',
|
background: 'rgba(16,18,22,0.72)',
|
||||||
borderRadius: '4px',
|
borderTop: '1px solid rgba(64,71,80,0.55)',
|
||||||
fontSize: '14px'
|
borderBottom: '1px solid rgba(34,38,44,0.96)',
|
||||||
|
borderLeft: '1px solid rgba(42,46,52,0.78)',
|
||||||
|
borderRight: '1px solid rgba(42,46,52,0.78)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '12px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: '500' }}>
|
<span style={{ fontWeight: 500, color: '#E6EBF2' }}>
|
||||||
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
|
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '12px',
|
borderRadius: '999px',
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
fontWeight: '500',
|
fontWeight: 500,
|
||||||
backgroundColor: value ? '#4CAF50' : '#f44336',
|
background: value ? 'rgba(47,162,82,.13)' : 'rgba(92,31,33,.4)',
|
||||||
color: 'white'
|
color: value ? '#2FA252' : '#EE6368',
|
||||||
|
border: `1px solid ${value ? 'rgba(47,162,82,.36)' : 'rgba(238,99,104,.42)'}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{String(value)}
|
{String(value)}
|
||||||
@@ -62,16 +63,12 @@ const FeatureFlagStatus: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button className="btn btn-secondary" onClick={reloadFlags} style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={handleReload}
|
|
||||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
|
||||||
>
|
|
||||||
Reload Flags
|
Reload Flags
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
|
<p style={{ fontSize: '12px', color: '#8D96A3', marginTop: '12px', marginBottom: 0 }}>
|
||||||
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
|
Feature flags are loaded from environment variables. Changes require updating the environment and reloading.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
||||||
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||||
import SquarePaymentNew from './SquarePaymentNew';
|
import SquarePaymentNew from './SquarePaymentNew';
|
||||||
|
import { londonTodayDateInput } from '../utils/timezone';
|
||||||
|
|
||||||
interface MembershipSetupProps {
|
interface MembershipSetupProps {
|
||||||
onMembershipCreated: () => void;
|
onMembershipCreated: () => void;
|
||||||
@@ -85,8 +86,10 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startDate = new Date().toISOString().split('T')[0];
|
const startDate = londonTodayDateInput();
|
||||||
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
const endDateValue = new Date(`${startDate}T00:00:00Z`);
|
||||||
|
endDateValue.setUTCFullYear(endDateValue.getUTCFullYear() + 1);
|
||||||
|
const endDate = endDateValue.toISOString().split('T')[0];
|
||||||
|
|
||||||
const membershipData: MembershipCreateData = {
|
const membershipData: MembershipCreateData = {
|
||||||
tier_id: selectedTier.id,
|
tier_id: selectedTier.id,
|
||||||
@@ -112,47 +115,38 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
|
|
||||||
if (step === 'select') {
|
if (step === 'select') {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card member-card member-membership-setup">
|
||||||
<h3 style={{ marginBottom: '16px' }}>Choose Your Membership</h3>
|
<div className="member-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="member-card-kicker">Membership Setup</p>
|
||||||
|
<h3>Choose Your Membership</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '16px' }}>
|
<div className="membership-tier-grid">
|
||||||
{tiers.map(tier => (
|
{tiers.map(tier => (
|
||||||
<div
|
<div
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
style={{
|
className="membership-tier-card"
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.3s'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#0066cc';
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 102, 204, 0.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#ddd';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
onClick={() => handleTierSelect(tier)}
|
onClick={() => handleTierSelect(tier)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
<div className="membership-tier-header">
|
||||||
<h4 style={{ margin: 0, color: '#0066cc' }}>{tier.name}</h4>
|
<h4>{tier.name}</h4>
|
||||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#0066cc' }}>
|
<span className="membership-tier-price">
|
||||||
£{tier.annual_fee.toFixed(2)}/year
|
£{tier.annual_fee.toFixed(2)}/year
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: '8px 0', color: '#666', fontSize: '14px' }}>{tier.description}</p>
|
<p className="membership-tier-description">{tier.description}</p>
|
||||||
<div style={{ backgroundColor: '#f5f5f5', padding: '12px', borderRadius: '4px' }}>
|
<div className="membership-tier-benefits">
|
||||||
<strong>Benefits:</strong>
|
<strong>Benefits:</strong>
|
||||||
<p style={{ marginTop: '4px', fontSize: '14px' }}>{tier.benefits}</p>
|
<p>{tier.benefits}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -167,12 +161,17 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
|
|
||||||
if (step === 'payment') {
|
if (step === 'payment') {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card member-card member-membership-setup">
|
||||||
<h3 style={{ marginBottom: '16px' }}>Complete Payment</h3>
|
<div className="member-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="member-card-kicker">Membership Setup</p>
|
||||||
|
<h3>Complete Payment</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
{selectedTier && (
|
{selectedTier && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div className="membership-summary-panel">
|
||||||
<h4>Selected Membership: {selectedTier.name}</h4>
|
<h4>Selected Membership: {selectedTier.name}</h4>
|
||||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||||
<p><strong>Benefits:</strong> {selectedTier.benefits}</p>
|
<p><strong>Benefits:</strong> {selectedTier.benefits}</p>
|
||||||
@@ -180,25 +179,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!paymentMethod && (
|
{!paymentMethod && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div className="membership-payment-stage">
|
||||||
<h4 style={{ marginBottom: '16px' }}>Choose Payment Method</h4>
|
<h4 className="membership-payment-heading">Choose Payment Method</h4>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '12px' }}>
|
<div className="membership-payment-options">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handlePaymentMethodSelect('square')}
|
onClick={() => handlePaymentMethodSelect('square')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
style={{ textAlign: 'left' }}
|
||||||
padding: '16px',
|
|
||||||
textAlign: 'left',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="membership-payment-option-copy">
|
||||||
<strong>Credit/Debit Card</strong>
|
<strong>Credit/Debit Card</strong>
|
||||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
<div>
|
||||||
Pay securely with Square
|
Pay securely with Square
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,17 +203,11 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => handlePaymentMethodSelect('cash')}
|
onClick={() => handlePaymentMethodSelect('cash')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
style={{ textAlign: 'left' }}
|
||||||
padding: '16px',
|
|
||||||
textAlign: 'left',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="membership-payment-option-copy">
|
||||||
<strong>Cash Payment</strong>
|
<strong>Cash Payment</strong>
|
||||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
<div>
|
||||||
Pay in person or by check
|
Pay in person or by check
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +216,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -250,7 +237,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
onPaymentSuccess={handleSquarePaymentSuccess}
|
onPaymentSuccess={handleSquarePaymentSuccess}
|
||||||
onPaymentError={handleSquarePaymentError}
|
onPaymentError={handleSquarePaymentError}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -268,26 +255,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
|
|
||||||
{paymentMethod === 'cash' && createdMembershipId && (
|
{paymentMethod === 'cash' && createdMembershipId && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div className="membership-cash-notice">
|
||||||
backgroundColor: '#fff3cd',
|
|
||||||
border: '1px solid #ffeaa7',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '16px',
|
|
||||||
marginBottom: '20px'
|
|
||||||
}}>
|
|
||||||
<strong>Cash Payment Selected</strong>
|
<strong>Cash Payment Selected</strong>
|
||||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
<p>
|
||||||
Your membership will be marked as pending until an administrator confirms payment receipt.
|
Your membership will be marked as pending until an administrator confirms payment receipt.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div className="membership-action-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handleCashPayment}
|
onClick={handleCashPayment}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ marginRight: '10px' }}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
||||||
</button>
|
</button>
|
||||||
@@ -314,13 +294,18 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
const isCashPayment = paymentMethod === 'cash';
|
const isCashPayment = paymentMethod === 'cash';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card member-card member-membership-setup">
|
||||||
<h3 style={{ marginBottom: '16px' }}>
|
<div className="member-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="member-card-kicker">Membership Setup</p>
|
||||||
|
<h3>
|
||||||
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedTier && (
|
{selectedTier && (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div className="membership-summary-panel">
|
||||||
<h4>Your Membership Details:</h4>
|
<h4>Your Membership Details:</h4>
|
||||||
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
||||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||||
@@ -329,7 +314,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
{isCashPayment ? 'Pending' : 'Active'}
|
{isCashPayment ? 'Pending' : 'Active'}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
|
<p className="membership-confirm-copy">
|
||||||
{isCashPayment
|
{isCashPayment
|
||||||
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
|
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
|
||||||
: 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.'
|
: 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.'
|
||||||
@@ -338,7 +323,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div className="membership-setup-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authService, User } from '../services/membershipService';
|
import { authService, User } from '../services/membershipService';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { formatLondonDate } from '../utils/timezone';
|
||||||
|
|
||||||
interface ProfileMenuProps {
|
interface ProfileMenuProps {
|
||||||
userName: string;
|
userName: string;
|
||||||
@@ -38,115 +40,55 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile
|
|||||||
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 */}
|
|
||||||
<button
|
<button
|
||||||
style={{
|
className={`profile-menu-item ${user ? '' : 'first'}`}
|
||||||
...menuItemStyle,
|
|
||||||
borderRadius: user ? '0' : '4px 4px 0 0',
|
|
||||||
borderTop: 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
|
||||||
@@ -167,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('');
|
||||||
@@ -195,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');
|
||||||
@@ -254,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>
|
||||||
|
|||||||
@@ -14,22 +14,16 @@ interface ProfileQuestionsFormProps {
|
|||||||
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
|
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
|
||||||
saveLabel?: string;
|
saveLabel?: string;
|
||||||
allowAdminManagedEdit?: boolean;
|
allowAdminManagedEdit?: boolean;
|
||||||
|
surface?: 'member' | 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
|
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') return 'Not set';
|
||||||
return 'Not set';
|
if (question.input_type === 'boolean') return value === true || value === 'true' ? 'Yes' : 'No';
|
||||||
}
|
|
||||||
|
|
||||||
if (question.input_type === 'boolean') {
|
|
||||||
return value === true || value === 'true' ? 'Yes' : 'No';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (question.input_type === 'select') {
|
if (question.input_type === 'select') {
|
||||||
const matchingOption = question.options.find((option) => option.value === String(value));
|
const matchingOption = question.options.find((option) => option.value === String(value));
|
||||||
return matchingOption?.label || String(value);
|
return matchingOption?.label || String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(value);
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +33,8 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
questions,
|
questions,
|
||||||
onSave,
|
onSave,
|
||||||
saveLabel = 'Save Answers',
|
saveLabel = 'Save Answers',
|
||||||
allowAdminManagedEdit = false
|
allowAdminManagedEdit = false,
|
||||||
|
surface = 'admin'
|
||||||
}) => {
|
}) => {
|
||||||
const initialAnswers = useMemo(() => {
|
const initialAnswers = useMemo(() => {
|
||||||
const values: Record<number, ProfileQuestionAnswerValue> = {};
|
const values: Record<number, ProfileQuestionAnswerValue> = {};
|
||||||
@@ -55,7 +50,6 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,17 +61,13 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
const visibleQuestions = useMemo(() => {
|
const visibleQuestions = useMemo(() => {
|
||||||
const byId = new Map<number, ProfileQuestionForUser>();
|
const byId = new Map<number, ProfileQuestionForUser>();
|
||||||
questions.forEach((question) => byId.set(question.id, question));
|
questions.forEach((question) => byId.set(question.id, question));
|
||||||
|
|
||||||
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
|
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
|
||||||
}, [questions, answers]);
|
}, [questions, answers]);
|
||||||
|
|
||||||
const filteredQuestions = useMemo(() => {
|
const filteredQuestions = useMemo(() => {
|
||||||
const searchTerm = search.trim().toLowerCase();
|
const searchTerm = search.trim().toLowerCase();
|
||||||
return visibleQuestions
|
return visibleQuestions.filter((question) => {
|
||||||
.filter((question) => {
|
if (!searchTerm) return true;
|
||||||
if (!searchTerm) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
question.label.toLowerCase().includes(searchTerm) ||
|
question.label.toLowerCase().includes(searchTerm) ||
|
||||||
question.key.toLowerCase().includes(searchTerm) ||
|
question.key.toLowerCase().includes(searchTerm) ||
|
||||||
@@ -86,20 +76,16 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
});
|
});
|
||||||
}, [visibleQuestions, search]);
|
}, [visibleQuestions, search]);
|
||||||
|
|
||||||
const paginatedQuestions = useMemo(() => {
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
||||||
|
|
||||||
|
const paginatedQuestions = useMemo(() => {
|
||||||
const safePage = Math.min(page, totalPages);
|
const safePage = Math.min(page, totalPages);
|
||||||
const start = (safePage - 1) * pageSize;
|
const start = (safePage - 1) * pageSize;
|
||||||
return filteredQuestions.slice(start, start + pageSize);
|
return filteredQuestions.slice(start, start + pageSize);
|
||||||
}, [filteredQuestions, page]);
|
}, [filteredQuestions, page, totalPages]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
|
||||||
|
|
||||||
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
|
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
|
||||||
setAnswers((prev) => ({
|
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||||
...prev,
|
|
||||||
[questionId]: value
|
|
||||||
}));
|
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
@@ -117,11 +103,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
|
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
|
||||||
const changedAnswers: ProfileAnswerInput[] = questions
|
const changedAnswers: ProfileAnswerInput[] = questions
|
||||||
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
|
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
|
||||||
.filter((question) => {
|
.filter((question) => answerToComparable(answers[question.id] ?? null) !== answerToComparable(initialAnswers[question.id] ?? null))
|
||||||
const current = answerToComparable(answers[question.id] ?? null);
|
|
||||||
const initial = answerToComparable(initialAnswers[question.id] ?? null);
|
|
||||||
return current !== initial;
|
|
||||||
})
|
|
||||||
.map((question) => ({
|
.map((question) => ({
|
||||||
question_id: question.id,
|
question_id: question.id,
|
||||||
value: answers[question.id] ?? null
|
value: answers[question.id] ?? null
|
||||||
@@ -141,27 +123,16 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
|
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
|
||||||
|
|
||||||
if (disabled && !saving) {
|
if (disabled && !saving) {
|
||||||
return (
|
return <div className="profile-question-readonly">{formatAnswerForDisplay(question, value)}</div>;
|
||||||
<div className="profile-question-readonly">
|
|
||||||
{formatAnswerForDisplay(question, value)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (question.input_type === 'boolean') {
|
if (question.input_type === 'boolean') {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={value === null ? '' : String(value)}
|
value={value === null ? '' : String(value)}
|
||||||
onChange={(event) => {
|
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : event.target.value === 'true')}
|
||||||
const nextValue = event.target.value;
|
|
||||||
if (nextValue === '') {
|
|
||||||
setAnswerValue(question.id, null);
|
|
||||||
} else {
|
|
||||||
setAnswerValue(question.id, nextValue === 'true');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
className="profile-question-input"
|
||||||
>
|
>
|
||||||
<option value="">Prefer not to say</option>
|
<option value="">Prefer not to say</option>
|
||||||
<option value="true">Yes</option>
|
<option value="true">Yes</option>
|
||||||
@@ -176,13 +147,11 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
value={value === null ? '' : String(value)}
|
value={value === null ? '' : String(value)}
|
||||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
className="profile-question-input"
|
||||||
>
|
>
|
||||||
<option value="">Select an option</option>
|
<option value="">Select an option</option>
|
||||||
{question.options.map((option) => (
|
{question.options.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
@@ -195,7 +164,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
value={value === null ? '' : String(value)}
|
value={value === null ? '' : String(value)}
|
||||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
className="profile-question-input"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -208,7 +177,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
|
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={question.placeholder || ''}
|
placeholder={question.placeholder || ''}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
className="profile-question-input"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,60 +189,51 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={question.placeholder || ''}
|
placeholder={question.placeholder || ''}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
className="profile-question-input"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginTop: '20px' }}>
|
<div className={`card profile-questions-form ${surface === 'member' ? 'member-surface' : 'admin-surface'}`}>
|
||||||
<h3 style={{ marginBottom: '8px' }}>{title}</h3>
|
<h3 className="profile-questions-title">{title}</h3>
|
||||||
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>}
|
{description && <p className="profile-questions-description">{description}</p>}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}>
|
<div className="profile-questions-search">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search questions..."
|
placeholder="Search questions..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }}
|
className="profile-question-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
<div className="alert alert-error">
|
{successMessage && <div className="alert alert-success">{successMessage}</div>}
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{successMessage && (
|
|
||||||
<div className="alert alert-success">
|
|
||||||
{successMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredQuestions.length === 0 ? (
|
{filteredQuestions.length === 0 ? (
|
||||||
<p style={{ color: '#666' }}>No questions available.</p>
|
<p className="profile-questions-empty">No questions available.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '16px' }}>
|
<div className="profile-questions-list">
|
||||||
{paginatedQuestions.map((question) => (
|
{paginatedQuestions.map((question) => (
|
||||||
<div key={question.id} className="profile-question-row">
|
<div
|
||||||
|
key={question.id}
|
||||||
|
className={`profile-question-row ${surface === 'member' ? 'profile-question-row-member' : 'profile-question-row-admin'}`}
|
||||||
|
>
|
||||||
<div className="profile-question-meta">
|
<div className="profile-question-meta">
|
||||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}>
|
<label className="profile-question-label">
|
||||||
{question.label}
|
{question.label}
|
||||||
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>}
|
{question.is_required && <span className="profile-question-required"> *</span>}
|
||||||
{question.admin_only_edit && (
|
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
|
||||||
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
|
|
||||||
Admin Managed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
{question.help_text && (
|
{question.help_text && (
|
||||||
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p>
|
<p className="profile-question-help">{question.help_text}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-question-answer">{renderField(question)}</div>
|
<div className="profile-question-answer">{renderField(question)}</div>
|
||||||
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
|
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
|
||||||
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}>
|
<p className="profile-question-lock-note">
|
||||||
This field can only be changed by an admin.
|
This field can only be changed by an admin.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -283,22 +243,22 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredQuestions.length > pageSize && (
|
{filteredQuestions.length > pageSize && (
|
||||||
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
|
<div className="profile-questions-pagination">
|
||||||
<span style={{ fontSize: '13px', color: '#525a66' }}>
|
<span className="profile-questions-page-copy">
|
||||||
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
|
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div className="profile-questions-pager-buttons">
|
||||||
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
<button className="btn btn-secondary profile-questions-pager-button" disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
|
<button className="btn btn-secondary profile-questions-pager-button" disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
|
<div className="profile-questions-actions">
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
{saving ? 'Saving...' : saveLabel}
|
{saving ? 'Saving...' : saveLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,98 +177,74 @@ interface TierManagementProps {
|
|||||||
export 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}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
+1329
-578
File diff suppressed because it is too large
Load Diff
@@ -26,13 +26,33 @@ 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>
|
||||||
|
|
||||||
|
<main className="auth-container">
|
||||||
|
<section className="auth-welcome-card">
|
||||||
|
<div className="auth-kicker">Password Help</div>
|
||||||
|
<h2>Recover access quickly</h2>
|
||||||
|
<p>
|
||||||
|
Enter the email address tied to your account and we'll send a secure password reset link if that account exists.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="auth-card">
|
||||||
|
<div className="auth-card-head">
|
||||||
|
<h2>Forgot Password</h2>
|
||||||
|
<span>Email reset link</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-card-body">
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
{message && <div className="alert alert-success">{message}</div>}
|
{message && <div className="alert alert-success">{message}</div>}
|
||||||
|
|
||||||
@@ -45,27 +65,29 @@ const ForgotPassword: React.FC = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
placeholder="Enter your email address"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary auth-submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ width: '100%', marginTop: '16px' }}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-footer">
|
<div className="auth-footer">
|
||||||
<Link to="/login" style={{ color: '#0066cc', textDecoration: 'none' }}>
|
<div>
|
||||||
Back to login
|
<Link to="/login">Back to login</Link>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,31 +43,38 @@ 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',
|
</div>
|
||||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.1)'
|
</div>
|
||||||
}}>
|
</header>
|
||||||
<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' }}>
|
<main className="auth-container">
|
||||||
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.
|
<section className="auth-welcome-card">
|
||||||
</p>
|
<div className="auth-kicker">Community Access</div>
|
||||||
<p style={{ fontSize: '1rem', color: '#555', lineHeight: '1.5' }}>
|
<h2>Welcome to SASA</h2>
|
||||||
Join our community of aviation enthusiasts and support the future of Swansea Airport.
|
<p>
|
||||||
|
Swansea Airport Stakeholder's Association manages member access, events, and operations from one shared platform.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="auth-card" style={{ flex: '1', maxWidth: '400px' }}>
|
<div className="auth-card-body">
|
||||||
<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>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -97,30 +104,28 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary auth-submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ width: '100%', marginTop: '16px' }}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Logging in...' : 'Log In'}
|
{loading ? 'Signing In...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-footer">
|
<div className="form-footer auth-footer">
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div>
|
||||||
<Link to="/forgot-password" style={{ color: '#0066cc', textDecoration: 'none' }}>
|
<Link to="/forgot-password">Forgot your password?</Link>
|
||||||
Forgot your password?
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary auth-submit"
|
||||||
onClick={() => navigate('/register')}
|
onClick={() => navigate('/register')}
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
>
|
||||||
Join SASA
|
Join SASA
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../contexts/ConfirmContext';
|
||||||
|
|
||||||
const MembershipTiers: React.FC = () => {
|
const MembershipTiers: React.FC = () => {
|
||||||
|
const toast = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -20,7 +24,7 @@ const MembershipTiers: React.FC = () => {
|
|||||||
setTiers(tierData);
|
setTiers(tierData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tiers:', error);
|
console.error('Failed to load tiers:', error);
|
||||||
alert('Failed to load membership tiers');
|
toast.error('Failed to load membership tiers.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -32,7 +36,7 @@ const MembershipTiers: React.FC = () => {
|
|||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.response?.data?.detail || 'Failed to create tier');
|
toast.error(error.response?.data?.detail || 'Failed to create tier.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,12 +46,18 @@ const MembershipTiers: React.FC = () => {
|
|||||||
setEditingTier(null);
|
setEditingTier(null);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.response?.data?.detail || 'Failed to update tier');
|
toast.error(error.response?.data?.detail || 'Failed to update tier.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTier = async (tierId: number) => {
|
const handleDeleteTier = async (tierId: number) => {
|
||||||
if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) {
|
const confirmed = await confirm({
|
||||||
|
title: 'Delete membership tier',
|
||||||
|
message: 'Are you sure you want to delete this membership tier? This action cannot be undone.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
tone: 'danger'
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +65,7 @@ const MembershipTiers: React.FC = () => {
|
|||||||
await membershipService.deleteTier(tierId);
|
await membershipService.deleteTier(tierId);
|
||||||
loadTiers();
|
loadTiers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.response?.data?.detail || 'Failed to delete tier');
|
toast.error(error.response?.data?.detail || 'Failed to delete tier.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +67,45 @@ 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">
|
||||||
|
<h1>SASA Member Portal</h1>
|
||||||
|
<div className="portal-subtitle">Membership registration and profile setup</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>
|
</p>
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</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' }}>
|
<form onSubmit={handleSubmit} className="auth-form-grid">
|
||||||
{/* Left Column - Personal Information */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="first_name">First Name *</label>
|
<label htmlFor="first_name">First Name *</label>
|
||||||
<input
|
<input
|
||||||
@@ -87,6 +114,7 @@ const Register: React.FC = () => {
|
|||||||
name="first_name"
|
name="first_name"
|
||||||
value={formData.first_name}
|
value={formData.first_name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
autoComplete="given-name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,6 +127,7 @@ const Register: React.FC = () => {
|
|||||||
name="last_name"
|
name="last_name"
|
||||||
value={formData.last_name}
|
value={formData.last_name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
autoComplete="family-name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +140,23 @@ const Register: React.FC = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label htmlFor="password">Password *</label>
|
<label htmlFor="password">Password *</label>
|
||||||
<input
|
<input
|
||||||
@@ -123,12 +165,11 @@ const Register: React.FC = () => {
|
|||||||
name="password"
|
name="password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handlePasswordChange}
|
onChange={handlePasswordChange}
|
||||||
|
autoComplete="new-password"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<small style={{ color: '#666', fontSize: '12px' }}>
|
<small className="form-hint">Minimum 8 characters.</small>
|
||||||
Minimum 8 characters
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -139,70 +180,51 @@ const Register: React.FC = () => {
|
|||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={handlePasswordChange}
|
onChange={handlePasswordChange}
|
||||||
|
autoComplete="new-password"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
|
className={confirmPassword ? (passwordsMatch ? 'field-success' : 'field-error') : ''}
|
||||||
required
|
required
|
||||||
style={{
|
|
||||||
borderColor: confirmPassword && !passwordsMatch ? '#dc3545' : confirmPassword && passwordsMatch ? '#28a745' : undefined
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{confirmPassword && (
|
{confirmPassword ? (
|
||||||
<small style={{
|
<small className={passwordsMatch ? 'form-hint hint-success' : 'form-hint hint-error'}>
|
||||||
color: passwordsMatch ? '#28a745' : '#dc3545',
|
{passwordsMatch ? 'Passwords match.' : 'Passwords do not match.'}
|
||||||
fontSize: '12px'
|
|
||||||
}}>
|
|
||||||
{passwordsMatch ? '✓ Passwords match' : '✗ Passwords do not match'}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
{!confirmPassword && (
|
|
||||||
<small style={{ color: '#666', fontSize: '12px' }}>
|
|
||||||
Re-enter your password
|
|
||||||
</small>
|
</small>
|
||||||
|
) : (
|
||||||
|
<small className="form-hint">Re-enter your password to confirm it.</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Contact Information */}
|
<div className="form-group form-group-full">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<label htmlFor="address">Address</label>
|
||||||
<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
|
<textarea
|
||||||
id="address"
|
id="address"
|
||||||
name="address"
|
name="address"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
rows={3}
|
rows={4}
|
||||||
|
autoComplete="street-address"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button - Full Width */}
|
<div className="form-group-full">
|
||||||
<div style={{ gridColumn: '1 / -1', marginTop: '8px' }}>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary auth-submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Creating Account...' : 'Create Account & Sign In'}
|
{loading ? 'Creating Account...' : 'Create Account & Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-footer">
|
<div className="auth-footer">
|
||||||
Already have an account? <a href="/login">Log in</a>
|
<div>
|
||||||
|
Already have an account? <Link to="/login">Log in</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,32 +55,74 @@ 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">
|
||||||
|
<h1>SASA Member Portal</h1>
|
||||||
|
<div className="portal-subtitle">Account recovery</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="auth-container">
|
||||||
|
<section className="auth-welcome-card">
|
||||||
|
<div className="auth-kicker">Link Expired</div>
|
||||||
|
<h2>This reset link can’t be used</h2>
|
||||||
|
<p>
|
||||||
|
The link is missing or no longer valid. Request a fresh reset email and try again from the newest message.
|
||||||
</p>
|
</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
|
<button
|
||||||
onClick={() => navigate('/forgot-password')}
|
onClick={() => navigate('/forgot-password')}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary auth-submit"
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
>
|
||||||
Request New Reset Link
|
Request New Reset Link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<main className="auth-container">
|
||||||
|
<section className="auth-welcome-card">
|
||||||
|
<div className="auth-kicker">Secure Reset</div>
|
||||||
|
<h2>Set a fresh password</h2>
|
||||||
|
<p>
|
||||||
|
Use a password with at least 8 characters. After a successful reset, you'll be returned to the login screen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="auth-card">
|
||||||
|
<div className="auth-card-head">
|
||||||
|
<h2>Reset Password</h2>
|
||||||
|
<span>Secure update</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-card-body">
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
{message && <div className="alert alert-success">{message}</div>}
|
{message && <div className="alert alert-success">{message}</div>}
|
||||||
|
|
||||||
@@ -93,6 +135,7 @@ const ResetPassword: React.FC = () => {
|
|||||||
name="newPassword"
|
name="newPassword"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
/>
|
/>
|
||||||
@@ -106,6 +149,7 @@ const ResetPassword: React.FC = () => {
|
|||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
/>
|
/>
|
||||||
@@ -113,14 +157,15 @@ const ResetPassword: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary auth-submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ width: '100%', marginTop: '16px' }}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Resetting...' : 'Reset Password'}
|
{loading ? 'Resetting...' : 'Reset Password'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -220,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);
|
||||||
@@ -409,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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -438,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,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();
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user