2 Commits

25 changed files with 7373 additions and 5 deletions
+23
View File
@@ -0,0 +1,23 @@
# An .aiignore file follows the same syntax as a .gitignore file.
# .gitignore documentation: https://git-scm.com/docs/gitignore
# you can ignore files
.DS_Store
*.log
*.tmp
# or folders
dist/
build/
out/
.idea/
/.aiignore
.aiignore
/.env
/.env.*
.env
.env.*
**/.env
**/.env.*
+7
View File
@@ -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
View File
@@ -0,0 +1,463 @@
# ESP RFID Integration Contract
The ESP32 reader is expected to host its own local setup/dashboard UI. The reader registers itself with the SASA server, waits for admin approval, receives an API key once, then uses that key for heartbeat, time sync, taps, dashboard login checks, and queued card-write jobs.
All timestamps are UTC ISO-8601 unless otherwise noted.
## 1. Reader Setup and Approval
### Register from the ESP dashboard
Unauthenticated endpoint. Use this when the reader first boots or when it is reset/reconfigured.
```http
POST /api/v1/esp/device/register
Content-Type: application/json
```
Request:
```json
{
"device_id": "front-desk-01",
"name": "Front Desk Reader",
"location": "Reception",
"reader_type": "checkin_checkout",
"can_write_cards": true,
"firmware_version": "esp32-rfid-0.1.0",
"notes": "PN532 over I2C"
}
```
Response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "pending",
"registration_token": "one-time-registration-token",
"message": "Registration received. Approve this reader in the admin panel.",
"poll_interval_seconds": 5
}
```
Firmware must store:
- `device_id`
- `registration_token`
### Admin approval
In the web portal:
- Dashboard -> Admin -> ESP RFID -> Setup
- Approve or reject pending readers.
When approved, the server does not show the key in the admin panel. The reader gets it through the provisioning poll below.
### Poll provisioning status
Use this while waiting for approval.
```http
GET /api/v1/esp/device/provisioning-status
X-ESP-Device-ID: front-desk-01
X-ESP-Registration-Token: one-time-registration-token
```
Pending response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "pending",
"message": "Waiting for admin approval.",
"api_key": null,
"poll_interval_seconds": 5
}
```
Approved response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "provisioned",
"message": "Reader approved. Store this API key; it will not be returned again.",
"api_key": "generated-api-key",
"poll_interval_seconds": 5
}
```
Firmware must store `api_key` permanently. The server only returns it once. If it is lost, either rotate the key in admin and manually enter it on the ESP, or re-register the reader.
Rejected response:
```json
{
"device_id": "front-desk-01",
"provisioning_status": "rejected",
"message": "Reader registration rejected.",
"api_key": null,
"poll_interval_seconds": 5
}
```
## 2. Authenticated Reader Requests
After provisioning, every reader endpoint uses:
```http
X-ESP-Device-ID: front-desk-01
X-ESP-API-Key: generated-api-key
Content-Type: application/json
```
Invalid, inactive, unapproved, or rejected readers receive `401`.
## 3. Time Sync
The backend returns the server clock in UTC/Zulu. Firmware should treat this as the source of truth and display it locally as needed.
## 3. Local ESP Dashboard
The firmware in `ESP/main.cpp` serves the local dashboard on port `80`.
Access options:
- If WiFi connects, open `http://<reader-lan-ip>/`.
- If WiFi does not connect, the reader starts a setup AP named `RFID-Setup-XXXXXX`; connect to it and open `http://192.168.4.1/`.
Default local dashboard credentials are configured in `ESP/secrets.h`:
- Username: `admin`
- Password: `admin`
The dashboard shows:
- WiFi status and RSSI
- Provisioning status
- Current operating mode
- Server-synced clock
- Last heartbeat result
- Current write-job state
- Last card UID/action/result
The local UI also exposes controls for:
- Saving server/WiFi/reader configuration
- Registering or re-registering the reader
- Manually syncing time
- Manually polling for queued write jobs
- Clearing provisioning
- Rebooting
Local dashboard JSON endpoints:
- `GET /api/status`
- `POST /api/config`
- `POST /api/register`
- `POST /api/sync-time`
- `POST /api/poll-job`
- `POST /api/clear-provisioning`
- `POST /api/cancel-local-job`
- `POST /api/reboot`
These are local ESP endpoints, not SASA backend endpoints.
## 4. Heartbeat
The reader sends a heartbeat after provisioning so the server knows it is alive and so the ESP can show heartbeat status on its dashboard.
```http
POST /api/v1/esp/device/heartbeat
X-ESP-Device-ID: front-desk-01
X-ESP-API-Key: generated-api-key
Content-Type: application/json
```
Request:
```json
{
"mode": "idle",
"message": "Heartbeat OK",
"wifi_rssi": -54,
"free_heap": 184320,
"firmware_version": "esp32-rfid-0.2.0",
"active_write_job_id": null
}
```
Response:
```json
{
"ok": true,
"server_time_utc": "2026-05-05T10:15:30.123456",
"unix_ms": 1777976130123,
"heartbeat_interval_seconds": 10,
"time_poll_interval_seconds": 3,
"write_job_poll_interval_seconds": 3
}
```
Firmware modes currently used:
- `setup`
- `waiting approval`
- `idle`
- `syncing time`
- `heartbeat`
- `checking jobs`
- `ready to write`
- `writing card`
- `reporting tap`
- `error`
The backend updates `last_seen_at` when heartbeat succeeds.
## 5. Time Sync
The reader should poll every 3 seconds.
```http
GET /api/v1/esp/device/time
```
Response:
```json
{
"server_time_utc": "2026-05-05T10:15:30.123456",
"unix_ms": 1777976130123,
"poll_interval_seconds": 3
}
```
Use `unix_ms` to correct the ESP local clock. Do not convert the value to a local timezone before applying it on the device.
## 6. Check-In and Check-Out Taps
```http
POST /api/v1/esp/device/taps
```
Request:
```json
{
"card_uid": "04A1B2C3D4",
"tapped_at": "2026-05-05T10:15:30.123456",
"reader_type": "checkin_checkout"
}
```
`tapped_at` is optional. If omitted, server time is used. If provided, it is normalized to UTC before persistence.
Accepted check-in:
```json
{
"accepted": true,
"action": "check_in",
"message": "Checked in",
"server_time_utc": "2026-05-05T10:15:30.123456",
"tap_id": 123,
"session_id": 45,
"user_id": 7,
"user_name": "Jane Smith",
"checked_in_at": "2026-05-05T10:15:30.123456",
"checked_out_at": null,
"duration_seconds": null
}
```
Accepted check-out:
```json
{
"accepted": true,
"action": "check_out",
"message": "Checked out",
"server_time_utc": "2026-05-05T12:20:30.123456",
"tap_id": 124,
"session_id": 45,
"user_id": 7,
"user_name": "Jane Smith",
"checked_in_at": "2026-05-05T10:15:30.123456",
"checked_out_at": "2026-05-05T12:20:30.123456",
"duration_seconds": 7500
}
```
Denied:
```json
{
"accepted": false,
"action": "denied",
"message": "Unknown RFID card",
"server_time_utc": "2026-05-05T10:15:30.123456",
"tap_id": 125,
"session_id": null,
"user_id": null,
"user_name": null
}
```
The server logs denied taps so unknown cards can still be audited.
The stored tap, check-in, check-out, approval, heartbeat, and write-job timestamps are all UTC. The frontend renders those values in Europe/London, but the backend and ESP contract stay on UTC/Zulu.
## 7. Queued RFID Card Writes
Admins queue card writes in:
- Dashboard -> Admin -> ESP RFID -> Setup -> Queue Card Write
They select:
- User
- Writing reader
- Card label
The reader does not need an inbound network port. It polls for jobs.
### Poll next write job
Only readers with `can_write_cards=true` may use this endpoint.
```http
GET /api/v1/esp/device/write-jobs/next
```
No job response:
```json
null
```
Job response:
```json
{
"id": 20,
"reader_id": 3,
"user_id": 7,
"card_id": null,
"label": "Jane main card",
"status": "claimed",
"requested_by_user_id": 1,
"card_uid": null,
"write_payload": "{\"job_id\":20,\"user_id\":7,\"user_name\":\"Jane Smith\",\"label\":\"Jane main card\",\"issued_at\":\"2026-05-05T10:15:30.123456\"}",
"claimed_at": "2026-05-05T10:15:30.123456",
"completed_at": null,
"error_message": null,
"created_at": "2026-05-05T10:14:00.000000",
"updated_at": "2026-05-05T10:15:30.123456"
}
```
Firmware behavior:
1. Poll for a job.
2. If a job is returned, enter `ready to write` mode and show the target user/label on the ESP dashboard.
3. On the next card tap, write `write_payload` to the card if supported by the chosen RFID card type.
4. Report success/failure.
The included ESP firmware writes MIFARE Classic 1K/4K cards using the default key `FF FF FF FF FF FF`, starting at block 4 and skipping trailer blocks. Unsupported cards are reported as failed write jobs.
### Complete write job
```http
POST /api/v1/esp/device/write-jobs/20/complete
```
Success request:
```json
{
"success": true,
"card_uid": "04A1B2C3D4"
}
```
Failure request:
```json
{
"success": false,
"error_message": "Card write failed verification"
}
```
On success, the server creates or updates the RFID card record and assigns it to the selected user with the queued label.
## 8. ESP Local Dashboard Login
Use this when someone logs into the ESP-hosted dashboard.
```http
POST /api/v1/esp/device/dashboard-login
```
Request:
```json
{
"email": "admin@example.com",
"password": "password"
}
```
Response:
```json
{
"valid": true,
"user_id": 1,
"role": "admin",
"user_name": "Admin User"
}
```
Only `admin` and `super_admin` users return `valid: true`.
## 9. Admin API Summary
These endpoints use the normal portal JWT.
- `GET /api/v1/esp/admin/readers`
- `POST /api/v1/esp/admin/readers` manual reader/key creation fallback
- `PUT /api/v1/esp/admin/readers/{reader_id}`
- `POST /api/v1/esp/admin/readers/{reader_id}/approve`
- `POST /api/v1/esp/admin/readers/{reader_id}/reject`
- `DELETE /api/v1/esp/admin/readers/{reader_id}`
- `POST /api/v1/esp/device/heartbeat`
- `GET /api/v1/esp/admin/cards`
- `POST /api/v1/esp/admin/cards`
- `PUT /api/v1/esp/admin/cards/{card_id}`
- `GET /api/v1/esp/admin/write-jobs?limit=100`
- `POST /api/v1/esp/admin/write-jobs`
- `POST /api/v1/esp/admin/write-jobs/{job_id}/cancel`
- `GET /api/v1/esp/admin/taps?limit=100`
- `GET /api/v1/esp/admin/attendance?open_only=false&limit=100`
- `POST /api/v1/esp/admin/attendance/close-stale`
Stale close request:
```json
{
"checkout_hour": 17
}
```
The backend also runs stale closing automatically on startup and hourly. It closes sessions checked in before today's midnight, sets checkout to 17:00 on the check-in date, and marks `checkout_source` as `system`.
## 10. Time Contract Summary
- API responses use UTC/Zulu datetimes.
- ESP readers sync against `/api/v1/esp/device/time`.
- Reader-supplied timestamps are normalized to UTC before storage.
- The browser frontend displays these values in Europe/London for operators and members.
@@ -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")
+860
View File
@@ -0,0 +1,860 @@
import json
import secrets
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from ...api.dependencies import get_admin_user
from ...core.database import get_db
from ...core.datetime import to_utc_naive, to_zulu_iso, unix_ms_utc, utc_now
from ...core.security import (
get_machine_token_hash,
is_machine_token_hash,
verify_machine_token,
verify_password,
)
from ...models.models import (
AttendanceCheckoutSource,
AttendanceSession,
EspReader,
EspReaderProvisioningStatus,
EspTapAction,
RfidCard,
RfidCardWriteJob,
RfidTap,
RfidWriteJobStatus,
User,
UserRole,
)
from ...schemas import (
AttendanceSessionResponse,
EspDashboardLoginResponse,
EspHeartbeatRequest,
EspHeartbeatResponse,
EspReaderCreate,
EspReaderCreateResponse,
EspReaderProvisioningResponse,
EspReaderRegistrationRequest,
EspReaderRegistrationResponse,
EspReaderResponse,
EspReaderUpdate,
EspTimeResponse,
LoginRequest,
MessageResponse,
RfidCardCreate,
RfidCardResponse,
RfidCardUpdate,
RfidTapAdminResponse,
RfidTapRequest,
RfidTapResponse,
RfidWriteJobCompleteRequest,
RfidWriteJobCreate,
RfidWriteJobResponse,
StaleSessionCloseRequest,
StaleSessionCloseResponse,
)
from ...services.attendance_service import close_stale_attendance_sessions, duration_seconds
router = APIRouter()
READER_LAST_SEEN_WRITE_INTERVAL = timedelta(seconds=30)
def _normalize_card_uid(uid: str) -> str:
return uid.strip().upper().replace(" ", "")
def _new_api_key() -> str:
return secrets.token_urlsafe(32)
def _new_registration_token() -> str:
return secrets.token_urlsafe(24)
def _provisioning_status_value(value: object) -> str:
return getattr(value, "value", value)
def _compact_tap_response(tap: RfidTap) -> JSONResponse:
return JSONResponse(
content={
"ok": tap.accepted,
"a": getattr(tap.action, "value", tap.action),
"m": tap.message or "",
}
)
async def get_current_reader(
x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"),
x_esp_api_key: str = Header(..., alias="X-ESP-API-Key"),
db: Session = Depends(get_db),
) -> EspReader:
reader = db.query(EspReader).filter(EspReader.device_id == x_esp_device_id).first()
provisioning_status = _provisioning_status_value(reader.provisioning_status) if reader else None
credentials_valid = bool(
reader
and reader.is_active
and reader.api_key_hash
and provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]
and verify_machine_token(x_esp_api_key, reader.api_key_hash)
)
if (
not credentials_valid
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid ESP reader credentials",
)
now = utc_now()
should_persist_reader = False
if not is_machine_token_hash(reader.api_key_hash):
reader.api_key_hash = get_machine_token_hash(x_esp_api_key)
should_persist_reader = True
if (
reader.last_seen_at is None
or now - reader.last_seen_at >= READER_LAST_SEEN_WRITE_INTERVAL
):
reader.last_seen_at = now
should_persist_reader = True
if reader.pending_api_key:
reader.pending_api_key = None
should_persist_reader = True
if should_persist_reader:
db.commit()
return reader
def _get_reader_by_registration_token(
device_id: str,
registration_token: str,
db: Session,
) -> EspReader:
reader = db.query(EspReader).filter(EspReader.device_id == device_id).first()
credentials_valid = bool(
reader
and reader.registration_token_hash
and verify_machine_token(registration_token, reader.registration_token_hash)
)
if not credentials_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid reader registration token",
)
if not is_machine_token_hash(reader.registration_token_hash):
reader.registration_token_hash = get_machine_token_hash(registration_token)
db.commit()
return reader
@router.post("/device/register", response_model=EspReaderRegistrationResponse, status_code=status.HTTP_202_ACCEPTED)
async def register_reader(
registration: EspReaderRegistrationRequest,
db: Session = Depends(get_db),
):
existing = db.query(EspReader).filter(EspReader.device_id == registration.device_id).first()
existing_status = _provisioning_status_value(existing.provisioning_status) if existing else None
allow_recovery = bool(
existing
and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]
and existing.last_seen_at is None
)
if existing and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] and not allow_recovery:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Reader is already approved. Use its existing API key or rotate it from admin.",
)
registration_token = _new_registration_token()
now = utc_now()
if existing:
existing.name = registration.name
existing.location = registration.location
existing.reader_type = registration.reader_type
existing.can_write_cards = registration.can_write_cards
existing.firmware_version = registration.firmware_version
existing.notes = registration.notes
existing.registration_token_hash = get_machine_token_hash(registration_token)
existing.provisioning_status = (
EspReaderProvisioningStatus.APPROVED
if allow_recovery
else EspReaderProvisioningStatus.PENDING
)
existing.is_active = True
if not allow_recovery:
existing.pending_api_key = None
existing.updated_at = now
reader = existing
else:
reader = EspReader(
device_id=registration.device_id,
name=registration.name,
location=registration.location,
reader_type=registration.reader_type,
can_write_cards=registration.can_write_cards,
firmware_version=registration.firmware_version,
notes=registration.notes,
registration_token_hash=get_machine_token_hash(registration_token),
provisioning_status=EspReaderProvisioningStatus.PENDING,
is_active=True,
)
db.add(reader)
db.commit()
return {
"device_id": reader.device_id,
"provisioning_status": reader.provisioning_status,
"registration_token": registration_token,
"message": (
"Reader recovery accepted. Poll provisioning to receive the API key again."
if allow_recovery
else "Registration received. Approve this reader in the admin panel."
),
"poll_interval_seconds": 5,
}
@router.get("/device/provisioning-status", response_model=EspReaderProvisioningResponse)
async def get_provisioning_status(
x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"),
x_esp_registration_token: str = Header(..., alias="X-ESP-Registration-Token"),
db: Session = Depends(get_db),
):
reader = _get_reader_by_registration_token(x_esp_device_id, x_esp_registration_token, db)
provisioning_status = _provisioning_status_value(reader.provisioning_status)
if provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]:
api_key = reader.pending_api_key or _new_api_key()
now = utc_now()
if not reader.pending_api_key:
reader.api_key_hash = get_machine_token_hash(api_key)
reader.pending_api_key = api_key
reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED
reader.provisioned_at = now
reader.updated_at = now
db.commit()
payload = {
"device_id": reader.device_id,
"provisioning_status": EspReaderProvisioningStatus.PROVISIONED.value,
"message": "Reader approved. Store this API key; it will not be returned again.",
"api_key": api_key,
"apiKey": api_key,
"poll_interval_seconds": 5,
}
print(
f"[ESP] provisioning-status device={reader.device_id} status=provisioned has_api_key={bool(api_key)}"
)
return JSONResponse(content=payload)
messages = {
EspReaderProvisioningStatus.PENDING.value: "Waiting for admin approval.",
EspReaderProvisioningStatus.APPROVED.value: "Reader approved. API key is already available or will be generated shortly.",
EspReaderProvisioningStatus.PROVISIONED.value: "Reader already provisioned. Use the stored API key.",
EspReaderProvisioningStatus.REJECTED.value: "Reader registration rejected.",
}
payload = {
"device_id": reader.device_id,
"provisioning_status": provisioning_status,
"message": messages.get(provisioning_status, "Waiting for admin approval."),
"api_key": None,
"apiKey": None,
"poll_interval_seconds": 5,
}
print(
f"[ESP] provisioning-status device={reader.device_id} status={provisioning_status} has_api_key=False"
)
return JSONResponse(content=payload)
@router.get("/device/time", response_model=EspTimeResponse)
async def get_device_time(
reader: EspReader = Depends(get_current_reader),
):
now = utc_now()
return {
"server_time_utc": now,
"unix_ms": unix_ms_utc(now),
"poll_interval_seconds": 3,
}
@router.post("/device/heartbeat", response_model=EspHeartbeatResponse)
async def record_heartbeat(
heartbeat: EspHeartbeatRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
now = utc_now()
if heartbeat.firmware_version and heartbeat.firmware_version != reader.firmware_version:
reader.firmware_version = heartbeat.firmware_version
reader.last_seen_at = now
reader.updated_at = now
db.commit()
return {
"ok": True,
"server_time_utc": now,
"unix_ms": unix_ms_utc(now),
"heartbeat_interval_seconds": 10,
"time_poll_interval_seconds": 3,
"write_job_poll_interval_seconds": 3,
}
@router.post("/device/taps", response_model=RfidTapResponse)
async def record_tap(
tap_request: RfidTapRequest,
x_esp_compact_response: str | None = Header(None, alias="X-ESP-Compact-Response"),
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
compact_response = x_esp_compact_response == "1"
now = utc_now()
tapped_at = to_utc_naive(tap_request.tapped_at) or now
card_uid = _normalize_card_uid(tap_request.card_uid)
card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first()
tap = RfidTap(
reader_id=reader.id,
card_id=card.id if card else None,
user_id=card.user_id if card and card.is_active else None,
card_uid=card_uid,
action=EspTapAction.UNKNOWN,
accepted=False,
raw_payload=json.dumps(tap_request.model_dump(mode="json")),
tapped_at=tapped_at,
)
db.add(tap)
db.flush()
if not card:
tap.action = EspTapAction.DENIED
tap.message = "Unknown RFID card"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
}
if not card.is_active or not card.user_id:
tap.action = EspTapAction.DENIED
tap.message = "RFID card is inactive or unassigned"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
}
user = db.query(User).filter(User.id == card.user_id).first()
if not user or not user.is_active:
tap.action = EspTapAction.DENIED
tap.message = "User is inactive"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"user_id": card.user_id,
}
open_session = (
db.query(AttendanceSession)
.filter(
AttendanceSession.user_id == user.id,
AttendanceSession.is_open == True,
)
.order_by(AttendanceSession.checked_in_at.desc())
.first()
)
user_name = f"{user.first_name} {user.last_name}"
if open_session:
tap.action = EspTapAction.CHECK_OUT
tap.accepted = True
tap.message = "Checked out"
open_session.check_out_tap_id = tap.id
open_session.checked_out_at = tapped_at
open_session.checkout_source = AttendanceCheckoutSource.USER
open_session.duration_seconds = duration_seconds(open_session.checked_in_at, tapped_at)
open_session.is_open = False
open_session.updated_at = now
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": True,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"session_id": open_session.id,
"user_id": user.id,
"user_name": user_name,
"checked_in_at": open_session.checked_in_at,
"checked_out_at": open_session.checked_out_at,
"duration_seconds": open_session.duration_seconds,
}
tap.action = EspTapAction.CHECK_IN
tap.accepted = True
tap.message = "Checked in"
session = AttendanceSession(
user_id=user.id,
reader_id=reader.id,
check_in_tap_id=tap.id,
checked_in_at=tapped_at,
is_open=True,
)
db.add(session)
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": True,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"session_id": session.id,
"user_id": user.id,
"user_name": user_name,
"checked_in_at": session.checked_in_at,
}
@router.post("/device/dashboard-login", response_model=EspDashboardLoginResponse)
async def validate_dashboard_login(
login_data: LoginRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.email == login_data.email).first()
if (
not user
or not user.is_active
or user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN]
or not verify_password(login_data.password, user.hashed_password)
):
return {"valid": False}
return {
"valid": True,
"user_id": user.id,
"role": user.role,
"user_name": f"{user.first_name} {user.last_name}",
}
@router.get("/device/write-jobs/next", response_model=RfidWriteJobResponse | None)
async def get_next_write_job(
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
if not reader.can_write_cards:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reader is not enabled for card writing")
job = (
db.query(RfidCardWriteJob)
.filter(
RfidCardWriteJob.reader_id == reader.id,
RfidCardWriteJob.status == RfidWriteJobStatus.PENDING,
)
.order_by(RfidCardWriteJob.created_at.asc())
.first()
)
if not job:
return None
user = db.query(User).filter(User.id == job.user_id).first()
job.status = RfidWriteJobStatus.CLAIMED
job.claimed_at = utc_now()
job.write_payload = json.dumps(
{
"job_id": job.id,
"user_id": job.user_id,
"user_name": f"{user.first_name} {user.last_name}" if user else None,
"label": job.label,
"issued_at": to_zulu_iso(job.claimed_at),
}
)
db.commit()
db.refresh(job)
return job
@router.post("/device/write-jobs/{job_id}/complete", response_model=RfidWriteJobResponse)
async def complete_write_job(
job_id: int,
completion: RfidWriteJobCompleteRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
job = (
db.query(RfidCardWriteJob)
.filter(RfidCardWriteJob.id == job_id, RfidCardWriteJob.reader_id == reader.id)
.first()
)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found")
if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Write job is already closed")
now = utc_now()
if not completion.success:
job.status = RfidWriteJobStatus.FAILED
job.error_message = completion.error_message or "Reader reported write failure"
job.completed_at = now
job.updated_at = now
db.commit()
db.refresh(job)
return job
if not completion.card_uid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="card_uid is required when success is true")
card_uid = _normalize_card_uid(completion.card_uid)
card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first()
if card:
card.user_id = job.user_id
card.label = job.label
card.is_active = True
card.updated_at = now
else:
card = RfidCard(uid=card_uid, user_id=job.user_id, label=job.label, is_active=True)
db.add(card)
db.flush()
job.card_id = card.id
job.card_uid = card_uid
job.status = RfidWriteJobStatus.COMPLETED
job.completed_at = now
job.updated_at = now
db.commit()
db.refresh(job)
return job
@router.get("/admin/readers", response_model=List[EspReaderResponse])
async def list_readers(
include_inactive: bool = Query(True),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(EspReader)
if not include_inactive:
query = query.filter(EspReader.is_active == True)
return query.order_by(EspReader.name.asc()).all()
@router.post("/admin/readers", response_model=EspReaderCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_reader(
reader_data: EspReaderCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
existing = db.query(EspReader).filter(EspReader.device_id == reader_data.device_id).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Device ID already exists")
api_key = reader_data.api_key or _new_api_key()
reader = EspReader(
device_id=reader_data.device_id,
name=reader_data.name,
location=reader_data.location,
reader_type=reader_data.reader_type,
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
api_key_hash=get_machine_token_hash(api_key),
is_active=reader_data.is_active,
can_write_cards=reader_data.can_write_cards,
firmware_version=reader_data.firmware_version,
approved_at=utc_now(),
provisioned_at=utc_now(),
notes=reader_data.notes,
)
db.add(reader)
db.commit()
db.refresh(reader)
return EspReaderCreateResponse(
**EspReaderResponse.model_validate(reader).model_dump(),
api_key=api_key,
)
@router.post("/admin/readers/{reader_id}/approve", response_model=EspReaderResponse)
async def approve_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
if reader.provisioning_status == EspReaderProvisioningStatus.REJECTED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Rejected reader must register again")
now = utc_now()
reader.provisioning_status = EspReaderProvisioningStatus.APPROVED
reader.is_active = True
reader.approved_at = now
reader.updated_at = now
reader.api_key_hash = None
reader.pending_api_key = None
db.commit()
db.refresh(reader)
return reader
@router.post("/admin/readers/{reader_id}/reject", response_model=EspReaderResponse)
async def reject_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
reader.provisioning_status = EspReaderProvisioningStatus.REJECTED
reader.is_active = False
reader.pending_api_key = None
reader.updated_at = utc_now()
db.commit()
db.refresh(reader)
return reader
@router.put("/admin/readers/{reader_id}", response_model=EspReaderCreateResponse | EspReaderResponse)
async def update_reader(
reader_id: int,
reader_data: EspReaderUpdate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
update_data = reader_data.model_dump(exclude_unset=True)
rotate_api_key = update_data.pop("rotate_api_key", False)
for field, value in update_data.items():
setattr(reader, field, value)
new_api_key: Optional[str] = None
if rotate_api_key:
new_api_key = _new_api_key()
reader.api_key_hash = get_machine_token_hash(new_api_key)
reader.pending_api_key = None
reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED
reader.provisioned_at = utc_now()
reader.updated_at = utc_now()
db.commit()
db.refresh(reader)
if new_api_key:
return EspReaderCreateResponse(
**EspReaderResponse.model_validate(reader).model_dump(),
api_key=new_api_key,
)
return reader
@router.get("/admin/write-jobs", response_model=List[RfidWriteJobResponse])
async def list_write_jobs(
limit: int = Query(100, ge=1, le=500),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
return db.query(RfidCardWriteJob).order_by(RfidCardWriteJob.created_at.desc()).limit(limit).all()
@router.post("/admin/write-jobs", response_model=RfidWriteJobResponse, status_code=status.HTTP_201_CREATED)
async def queue_write_job(
job_data: RfidWriteJobCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == job_data.reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader does not exist")
provisioning_status = _provisioning_status_value(reader.provisioning_status)
if not reader.is_active or provisioning_status not in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not active and provisioned")
if not reader.can_write_cards:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not enabled for card writing")
user = db.query(User).filter(User.id == job_data.user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
job = RfidCardWriteJob(
reader_id=job_data.reader_id,
user_id=job_data.user_id,
label=job_data.label,
status=RfidWriteJobStatus.PENDING,
requested_by_user_id=admin_user.id,
)
db.add(job)
db.commit()
db.refresh(job)
return job
@router.post("/admin/write-jobs/{job_id}/cancel", response_model=RfidWriteJobResponse)
async def cancel_write_job(
job_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
job = db.query(RfidCardWriteJob).filter(RfidCardWriteJob.id == job_id).first()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found")
if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only pending or claimed jobs can be cancelled")
job.status = RfidWriteJobStatus.CANCELLED
job.updated_at = utc_now()
db.commit()
db.refresh(job)
return job
@router.get("/admin/cards", response_model=List[RfidCardResponse])
async def list_cards(
include_inactive: bool = Query(True),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(RfidCard)
if not include_inactive:
query = query.filter(RfidCard.is_active == True)
return query.order_by(RfidCard.uid.asc()).all()
@router.post("/admin/cards", response_model=RfidCardResponse, status_code=status.HTTP_201_CREATED)
async def create_card(
card_data: RfidCardCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
uid = _normalize_card_uid(card_data.uid)
existing = db.query(RfidCard).filter(RfidCard.uid == uid).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="RFID card already exists")
if card_data.user_id:
user = db.query(User).filter(User.id == card_data.user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
card = RfidCard(
uid=uid,
user_id=card_data.user_id,
label=card_data.label,
is_active=card_data.is_active,
)
db.add(card)
db.commit()
db.refresh(card)
return card
@router.put("/admin/cards/{card_id}", response_model=RfidCardResponse)
async def update_card(
card_id: int,
card_data: RfidCardUpdate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
card = db.query(RfidCard).filter(RfidCard.id == card_id).first()
if not card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="RFID card not found")
update_data = card_data.model_dump(exclude_unset=True)
if update_data.get("user_id"):
user = db.query(User).filter(User.id == update_data["user_id"]).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
for field, value in update_data.items():
setattr(card, field, value)
card.updated_at = utc_now()
db.commit()
db.refresh(card)
return card
@router.get("/admin/taps", response_model=List[RfidTapAdminResponse])
async def list_taps(
limit: int = Query(100, ge=1, le=500),
reader_id: Optional[int] = None,
user_id: Optional[int] = None,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(RfidTap)
if reader_id:
query = query.filter(RfidTap.reader_id == reader_id)
if user_id:
query = query.filter(RfidTap.user_id == user_id)
return query.order_by(RfidTap.tapped_at.desc()).limit(limit).all()
@router.get("/admin/attendance", response_model=List[AttendanceSessionResponse])
async def list_attendance_sessions(
open_only: bool = False,
limit: int = Query(100, ge=1, le=500),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(AttendanceSession)
if open_only:
query = query.filter(AttendanceSession.is_open == True)
return query.order_by(AttendanceSession.checked_in_at.desc()).limit(limit).all()
@router.post("/admin/attendance/close-stale", response_model=StaleSessionCloseResponse)
async def close_stale_sessions(
request: StaleSessionCloseRequest,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
closed_count = close_stale_attendance_sessions(
db,
cutoff_date=request.cutoff_date,
checkout_hour=request.checkout_hour,
)
return {"closed_count": closed_count}
@router.delete("/admin/readers/{reader_id}", response_model=MessageResponse)
async def delete_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
db.query(AttendanceSession).filter(AttendanceSession.reader_id == reader.id).delete(synchronize_session=False)
db.query(RfidTap).filter(RfidTap.reader_id == reader.id).delete(synchronize_session=False)
db.query(RfidCardWriteJob).filter(RfidCardWriteJob.reader_id == reader.id).delete(synchronize_session=False)
db.delete(reader)
db.commit()
return {"message": "Reader deleted"}
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timezone
def utc_now() -> datetime:
"""Naive UTC datetime for existing SQLAlchemy DateTime columns."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def to_utc_naive(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def to_zulu_iso(value: datetime) -> str:
return to_utc_aware(value).isoformat().replace("+00:00", "Z")
def unix_ms_utc(value: datetime) -> int:
return int(to_utc_aware(value).timestamp() * 1000)
@@ -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)
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime
from app.core.datetime import unix_ms_utc, utc_now
from app.schemas import EventCreate, EventResponse, EspTimeResponse
def test_event_input_datetime_is_normalized_to_utc_naive() -> None:
event = EventCreate(
title="Evening briefing",
event_date="2026-06-01T19:30:00+01:00",
event_time=None,
)
assert event.event_date == datetime(2026, 6, 1, 18, 30)
assert event.event_date.tzinfo is None
def test_response_datetimes_serialize_as_zulu() -> None:
event = EventResponse(
id=1,
title="Evening briefing",
description=None,
event_date=datetime(2026, 6, 1, 18, 30),
event_time=None,
location=None,
max_attendees=None,
status="draft",
created_by=1,
created_at=datetime(2026, 5, 1, 10, 0),
updated_at=datetime(2026, 5, 1, 10, 0),
)
payload = event.model_dump_json()
assert '"event_date":"2026-06-01T18:30:00Z"' in payload
assert '"created_at":"2026-05-01T10:00:00Z"' in payload
def test_esp_time_uses_same_utc_instant_for_iso_and_unix_ms() -> None:
now = utc_now()
response = EspTimeResponse(
server_time_utc=now,
unix_ms=unix_ms_utc(now),
)
assert '"server_time_utc":"' in response.model_dump_json()
assert response.unix_ms == unix_ms_utc(now)
+186
View File
@@ -0,0 +1,186 @@
import asyncio
import json
from types import SimpleNamespace
from app.api.v1 import esp
from app.core.security import get_machine_token_hash, get_password_hash, verify_machine_token
from app.models.models import EspReaderProvisioningStatus
from app.schemas import EspReaderRegistrationRequest
class _FakeDb:
def __init__(self, reader=None) -> None:
self.reader = reader
class _Query:
def __init__(self, reader) -> None:
self.reader = reader
def filter(self, *args, **kwargs):
return self
def first(self):
return self.reader
def commit(self) -> None:
return None
def add(self, _obj) -> None:
return None
def refresh(self, _obj) -> None:
return None
def query(self, _model):
return self._Query(self.reader)
def test_provisioning_status_returns_api_key_for_enum_status(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status=EspReaderProvisioningStatus.APPROVED,
pending_api_key=None,
api_key_hash=None,
provisioned_at=None,
updated_at=None,
)
monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader)
monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key")
response = asyncio.run(
esp.get_provisioning_status(
x_esp_device_id="esp32-123456",
x_esp_registration_token="token",
db=_FakeDb(),
)
)
payload = json.loads(response.body)
assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value
assert payload["api_key"] == "generated-api-key"
assert payload["apiKey"] == "generated-api-key"
def test_register_reader_allows_recovery_before_first_authenticated_call(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
name="Old Reader",
location="Old Location",
reader_type="checkin_checkout",
can_write_cards=False,
firmware_version="old-fw",
notes="old",
registration_token_hash="old-hash",
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
is_active=True,
pending_api_key="pending-api-key",
last_seen_at=None,
updated_at=None,
)
db = _FakeDb(reader)
monkeypatch.setattr(esp, "_new_registration_token", lambda: "replacement-token")
monkeypatch.setattr(esp, "get_machine_token_hash", lambda value: f"hashed:{value}")
response = asyncio.run(
esp.register_reader(
EspReaderRegistrationRequest(
device_id="esp32-123456",
name="Recovered Reader",
location="Front Desk",
reader_type="checkin_checkout",
can_write_cards=True,
firmware_version="new-fw",
notes="recovered",
),
db=db,
)
)
assert response["provisioning_status"] == EspReaderProvisioningStatus.APPROVED
assert response["registration_token"] == "replacement-token"
assert response["message"] == "Reader recovery accepted. Poll provisioning to receive the API key again."
assert reader.registration_token_hash == "hashed:replacement-token"
assert reader.pending_api_key == "pending-api-key"
assert reader.provisioning_status == EspReaderProvisioningStatus.APPROVED
def test_machine_token_hash_round_trip() -> None:
token = "esp-device-token"
stored_hash = get_machine_token_hash(token)
assert verify_machine_token(token, stored_hash) is True
assert verify_machine_token("wrong-token", stored_hash) is False
def test_machine_token_verify_supports_legacy_bcrypt_hash() -> None:
token = "legacy-esp-token"
stored_hash = get_password_hash(token)
assert verify_machine_token(token, stored_hash) is True
assert verify_machine_token("wrong-token", stored_hash) is False
def test_get_current_reader_migrates_legacy_bcrypt_api_key() -> None:
api_key = "legacy-api-key"
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
is_active=True,
api_key_hash=get_password_hash(api_key),
pending_api_key=None,
last_seen_at=None,
)
db = _FakeDb(reader)
response_reader = asyncio.run(
esp.get_current_reader(
x_esp_device_id="esp32-123456",
x_esp_api_key=api_key,
db=db,
)
)
assert response_reader is reader
assert reader.api_key_hash == get_machine_token_hash(api_key)
def test_compact_tap_response_uses_short_keys() -> None:
tap = SimpleNamespace(
accepted=True,
action=SimpleNamespace(value="check_in"),
message="Checked in",
)
response = esp._compact_tap_response(tap)
payload = json.loads(response.body)
assert payload == {"ok": True, "a": "check_in", "m": "Checked in"}
def test_provisioning_status_returns_api_key_for_string_status(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status="approved",
pending_api_key=None,
api_key_hash=None,
provisioned_at=None,
updated_at=None,
)
monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader)
monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key")
response = asyncio.run(
esp.get_provisioning_status(
x_esp_device_id="esp32-123456",
x_esp_registration_token="token",
db=_FakeDb(),
)
)
payload = json.loads(response.body)
assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value
assert payload["api_key"] == "generated-api-key"
assert payload["apiKey"] == "generated-api-key"
+3411
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
import React from 'react';
import { User } from '../../services/membershipService';
import ProfileMenu from '../ProfileMenu';
import PortalBrand from '../layout/PortalBrand';
interface DashboardTopbarProps {
activeTab: 'overview' | 'questions' | 'settings' | 'admin';
isAdmin: boolean;
isAdminWorkspace: boolean;
navigateToTab: (tab: 'overview' | 'questions' | 'settings' | 'admin') => void;
enterAdminArea: () => void;
exitAdminArea: () => void;
onEditProfile: () => void;
subtitle?: string;
user: User | null;
}
const userTabs: Array<{ key: 'overview' | 'questions' | 'settings'; label: string }> = [
{ key: 'overview', label: 'Overview' },
{ key: 'questions', label: 'Profile Questions' },
{ key: 'settings', label: 'Profile Settings' }
];
const DashboardTopbar: React.FC<DashboardTopbarProps> = ({
activeTab,
isAdmin,
isAdminWorkspace,
navigateToTab,
enterAdminArea,
exitAdminArea,
onEditProfile,
subtitle,
user
}) => (
<nav className={isAdminWorkspace ? 'portal-topbar portal-topbar-admin' : 'portal-topbar member-topbar'}>
<PortalBrand
title={isAdminWorkspace ? 'SASA Admin' : 'SASA Member Portal'}
subtitle={subtitle || (isAdminWorkspace ? 'Operations network' : `Welcome, ${user?.first_name || 'Member'}`)}
admin={isAdminWorkspace}
/>
{!isAdminWorkspace && (
<div className="portal-nav">
{userTabs.map((tab) => (
<button
key={tab.key}
className={activeTab === tab.key ? 'portal-tab active' : 'portal-tab'}
onClick={() => navigateToTab(tab.key)}
>
{tab.label}
</button>
))}
{isAdmin && (
<button className="portal-switch-button" onClick={enterAdminArea}>
Enter Admin Area
</button>
)}
</div>
)}
<div className="portal-meta">
{isAdminWorkspace && (
<button className="portal-exit-button" onClick={exitAdminArea}>
Back to User Space
</button>
)}
<ProfileMenu
userName={`${user?.first_name || ''} ${user?.last_name || ''}`.trim()}
userRole={user?.role || ''}
user={user}
onEditProfile={onEditProfile}
/>
</div>
</nav>
);
export default DashboardTopbar;
@@ -0,0 +1,14 @@
import React from 'react';
import { Link } from 'react-router-dom';
const AppFooter: React.FC = () => (
<footer className="site-footer">
<div>
<Link to="/privacy-policy">Privacy Policy</Link>
<Link to="/terms-of-service">Terms of Service</Link>
</div>
<div className="site-footer-caption">SASA Portal</div>
</footer>
);
export default AppFooter;
@@ -0,0 +1,18 @@
import React from 'react';
interface CookieBannerProps {
onDismiss: () => void;
}
const CookieBanner: React.FC<CookieBannerProps> = ({ onDismiss }) => (
<div className="cookie-banner">
<div>
We use cookies for session authentication, security, and basic site functionality.
</div>
<button className="btn btn-primary cookie-banner-button" onClick={onDismiss}>
OK
</button>
</div>
);
export default CookieBanner;
@@ -0,0 +1,19 @@
import React from 'react';
interface PortalBrandProps {
title: string;
subtitle: string;
admin?: boolean;
}
const PortalBrand: React.FC<PortalBrandProps> = ({ title, subtitle, admin = false }) => (
<div className="portal-brand">
<div className="portal-mark">S</div>
<div className={`portal-brand-text${admin ? ' admin-brand-text' : ''}`}>
<h1>{title}</h1>
<div className="portal-subtitle">{subtitle}</div>
</div>
</div>
);
export default PortalBrand;
+108
View File
@@ -0,0 +1,108 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'default' | 'danger';
}
interface ConfirmState extends ConfirmOptions {
open: boolean;
}
interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const lastLocationKeyRef = useRef(location.key);
const [dialog, setDialog] = useState<ConfirmState>({
open: false,
title: '',
message: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
tone: 'default'
});
const closeDialog = useCallback((result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setDialog((prev) => ({ ...prev, open: false }));
}, []);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
setDialog({
open: true,
title: options.title || 'Confirm action',
message: options.message,
confirmLabel: options.confirmLabel || 'Confirm',
cancelLabel: options.cancelLabel || 'Cancel',
tone: options.tone || 'default'
});
});
}, []);
const value = useMemo<ConfirmContextValue>(() => ({ confirm }), [confirm]);
useEffect(() => {
if (lastLocationKeyRef.current === location.key) {
return;
}
lastLocationKeyRef.current = location.key;
if (!dialog.open) {
return;
}
resolverRef.current?.(false);
resolverRef.current = null;
setDialog((prev) => ({ ...prev, open: false }));
}, [dialog.open, location.key]);
return (
<ConfirmContext.Provider value={value}>
{children}
{dialog.open && (
<div className="modal-overlay" onClick={() => closeDialog(false)}>
<div className="modal-content confirm-dialog" onClick={(event) => event.stopPropagation()}>
<h3 className={dialog.tone === 'danger' ? 'confirm-dialog-title danger' : 'confirm-dialog-title'}>
{dialog.title}
</h3>
<p className="confirm-dialog-message">{dialog.message}</p>
<div className="modal-button-row">
<button className="btn btn-secondary" type="button" onClick={() => closeDialog(false)}>
{dialog.cancelLabel}
</button>
<button
className={dialog.tone === 'danger' ? 'btn btn-danger' : 'btn btn-primary'}
type="button"
onClick={() => closeDialog(true)}
>
{dialog.confirmLabel}
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
);
};
export const useConfirm = (): ConfirmContextValue => {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return context;
};
+65
View File
@@ -0,0 +1,65 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
type ToastTone = 'success' | 'error' | 'info';
interface ToastItem {
id: number;
message: string;
tone: ToastTone;
}
interface ToastContextValue {
showToast: (message: string, tone?: ToastTone) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((message: string, tone: ToastTone = 'info') => {
const id = Date.now() + Math.floor(Math.random() * 1000);
setToasts((prev) => [...prev, { id, message, tone }]);
window.setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 5000);
}, []);
const value = useMemo<ToastContextValue>(() => ({
showToast,
success: (message) => showToast(message, 'success'),
error: (message) => showToast(message, 'error'),
info: (message) => showToast(message, 'info')
}), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-viewport" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.tone}`}>
<div className="toast-message">{toast.message}</div>
<button className="toast-close" type="button" onClick={() => dismissToast(toast.id)} aria-label="Dismiss notification">
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
@@ -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 -5
View File
@@ -28,15 +28,11 @@ export const canEditProfileQuestion = (
question: EditableProfileQuestion, question: EditableProfileQuestion,
allowAdminManagedEdit = false allowAdminManagedEdit = false
): boolean => { ): boolean => {
if (allowAdminManagedEdit) {
return true;
}
if (!question.can_edit) { if (!question.can_edit) {
return false; return false;
} }
if (question.admin_only_edit) { if (question.admin_only_edit && !allowAdminManagedEdit) {
return false; return false;
} }
+92
View File
@@ -0,0 +1,92 @@
const LONDON_TIME_ZONE = 'Europe/London';
const parseUtcDate = (value: string): Date => {
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value) ? value : `${value}Z`;
return new Date(normalized);
};
const londonParts = (date: Date) => {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(date);
const get = (type: string) => parts.find((part) => part.type === type)?.value || '00';
return {
year: get('year'),
month: get('month'),
day: get('day'),
hour: get('hour'),
minute: get('minute')
};
};
const timeZoneOffsetMs = (instant: Date): number => {
const parts = londonParts(instant);
const wallAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute)
);
return wallAsUtc - instant.getTime();
};
export const ensureUtcIso = (value: string): string => parseUtcDate(value).toISOString();
export const utcMillis = (value: string | null | undefined): number => {
if (!value) return 0;
return parseUtcDate(value).getTime();
};
export const formatLondonDate = (value: string): string => {
return new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(parseUtcDate(value));
};
export const formatLondonDateTime = (value: string | null): string => {
if (!value) return 'Never';
return new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).format(parseUtcDate(value));
};
export const utcToLondonDateInput = (value: string): string => {
const parts = londonParts(parseUtcDate(value));
return `${parts.year}-${parts.month}-${parts.day}`;
};
export const utcToLondonTimeInput = (value: string): string => {
const parts = londonParts(parseUtcDate(value));
return `${parts.hour}:${parts.minute}`;
};
export const londonTodayDateInput = (): string => {
const parts = londonParts(new Date());
return `${parts.year}-${parts.month}-${parts.day}`;
};
export const londonInputToUtcIso = (dateValue: string, timeValue: string = '00:00'): string => {
const [year, month, day] = dateValue.split('-').map(Number);
const [hour, minute] = timeValue.split(':').map(Number);
const wallAsUtc = Date.UTC(year, month - 1, day, hour || 0, minute || 0);
const firstPass = new Date(wallAsUtc - timeZoneOffsetMs(new Date(wallAsUtc)));
const secondPass = new Date(wallAsUtc - timeZoneOffsetMs(firstPass));
return secondPass.toISOString();
};