From d5c95eb77b3d7167db16f0a69e08c2fe1b0ad58a Mon Sep 17 00:00:00 2001 From: Dijo S Benelen Date: Wed, 12 Aug 2026 23:57:19 +0530 Subject: [PATCH 1/4] refactor: consolidate face processing assets --- .dockerignore | 1 + .gitignore | 3 ++- README.md | 12 +++++++----- deploy/aws/README.md | 2 +- deploy/aws/platform.yml | 6 +++--- deploy/aws/publish.sh | 15 +++++++++------ docker-compose.aws.yml | 2 +- docker-compose.yml | 4 ++-- face-processing/models/MANIFEST.sha256 | 2 ++ face-processing/models/detection/README.md | 3 +++ face-processing/models/recognition/README.md | 3 +++ face-processing/{ml => service}/Dockerfile | 4 ++-- .../{ml => service}/assets/warmup/einstein.jpeg | Bin .../requirements.txt} | 0 run-platform.sh | 2 +- tools/native_accurate_backend.py | 14 +++++++------- tools/verify_models.sh | 6 +++--- 17 files changed, 47 insertions(+), 32 deletions(-) create mode 100644 face-processing/models/MANIFEST.sha256 create mode 100644 face-processing/models/detection/README.md create mode 100644 face-processing/models/recognition/README.md rename face-processing/{ml => service}/Dockerfile (81%) rename face-processing/{ml => service}/assets/warmup/einstein.jpeg (100%) rename face-processing/{ml/requirements-local.txt => service/requirements.txt} (100%) diff --git a/.dockerignore b/.dockerignore index 8c9565b..46c3355 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ webapp/node_modules webapp/dist data +face-processing/models *.zip *.log __pycache__ diff --git a/.gitignore b/.gitignore index c6d9cd4..8fffa32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -models/ +/face-processing/models/detection/*.onnx +/face-processing/models/recognition/*.onnx .venv/ .env data/ diff --git a/README.md b/README.md index 96d77ab..2c11c40 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ FDX is a multi-tenant event-photo delivery platform implementing the workflow in - `webapp/` — React and Vite dashboards plus participant enrollment and private gallery pages. - `backend/` — FastAPI API, PostgreSQL models/Alembic migrations, authentication, storage, email, retention, and Kafka worker. -- `face-processing/ml/` — Gunicorn-hosted RetinaFace R50 and AdaFace IR101 service. +- `face-processing/service/` — Gunicorn-hosted face-processing inference service. +- `face-processing/models/detection/` — RetinaFace face-detection weights. +- `face-processing/models/recognition/` — AdaFace face-recognition weights. - `deploy/nginx/` — frontend hosting, reverse proxy, upload limits, and API rate limiting. - `deploy/aws/` — production CloudFormation and publishing workflow. - `tools/` — model integrity and end-to-end platform verification. @@ -15,11 +17,11 @@ PostgreSQL is the source of truth, Redis provides login rate limiting and health ## Required models -Place these files under `models/onnx/`: +Place each ONNX model under the directory matching its role: ```text -models/onnx/retinaface-r50.onnx -models/onnx/adaface-ir101-ms1mv2.onnx +face-processing/models/detection/retinaface-r50.onnx +face-processing/models/recognition/adaface-ir101-ms1mv2.onnx ``` Verify them with: @@ -67,7 +69,7 @@ node tools/verify_platform.mjs To include the real ML enrollment/matching/gallery path: ```sh -FDX_VERIFY_FACE_IMAGE=face-processing/ml/assets/warmup/einstein.jpeg \ +FDX_VERIFY_FACE_IMAGE=face-processing/service/assets/warmup/einstein.jpeg \ node tools/verify_platform.mjs ``` diff --git a/deploy/aws/README.md b/deploy/aws/README.md index aaa4c9d..9711d41 100644 --- a/deploy/aws/README.md +++ b/deploy/aws/README.md @@ -10,7 +10,7 @@ ## Prerequisites -Install AWS CLI v2 and Docker, configure an AWS account, place both ONNX model files under `models/onnx/`, request an ACM certificate in the deployment region, and move the SES account out of sandbox when sending outside verified recipients. +Install AWS CLI v2 and Docker, configure an AWS account, place RetinaFace under `face-processing/models/detection/` and AdaFace under `face-processing/models/recognition/`, request an ACM certificate in the deployment region, and move the SES account out of sandbox when sending outside verified recipients. Deploy the infrastructure (replace the example values): diff --git a/deploy/aws/platform.yml b/deploy/aws/platform.yml index 6dfb55b..9eb0d19 100644 --- a/deploy/aws/platform.yml +++ b/deploy/aws/platform.yml @@ -250,10 +250,10 @@ Resources: mkdir -p /usr/local/lib/docker/cli-plugins curl -fsSL https://github.com/docker/compose/releases/download/${DockerComposeVersion}/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose chmod +x /usr/local/lib/docker/cli-plugins/docker-compose - mkdir -p /opt/fdx/models/onnx + mkdir -p /opt/fdx/face-processing/models/detection /opt/fdx/face-processing/models/recognition until aws s3 cp s3://${MediaBucket}/deployments/docker-compose.aws.yml /opt/fdx/docker-compose.yml --region ${AWS::Region}; do sleep 30; done - aws s3 cp s3://${MediaBucket}/models/onnx/retinaface-r50.onnx /opt/fdx/models/onnx/retinaface-r50.onnx --region ${AWS::Region} - aws s3 cp s3://${MediaBucket}/models/onnx/adaface-ir101-ms1mv2.onnx /opt/fdx/models/onnx/adaface-ir101-ms1mv2.onnx --region ${AWS::Region} + aws s3 cp s3://${MediaBucket}/face-processing/models/detection/retinaface-r50.onnx /opt/fdx/face-processing/models/detection/retinaface-r50.onnx --region ${AWS::Region} + aws s3 cp s3://${MediaBucket}/face-processing/models/recognition/adaface-ir101-ms1mv2.onnx /opt/fdx/face-processing/models/recognition/adaface-ir101-ms1mv2.onnx --region ${AWS::Region} DB_SECRET=$(aws secretsmanager get-secret-value --secret-id ${DatabaseSecret} --query SecretString --output text --region ${AWS::Region}) JWT_SECRET_VALUE=$(aws secretsmanager get-secret-value --secret-id ${JwtSecret} --query SecretString --output text --region ${AWS::Region}) ADMIN_SECRET_VALUE=$(aws secretsmanager get-secret-value --secret-id ${AdminSecret} --query SecretString --output text --region ${AWS::Region}) diff --git a/deploy/aws/publish.sh b/deploy/aws/publish.sh index dd01c08..154ab78 100755 --- a/deploy/aws/publish.sh +++ b/deploy/aws/publish.sh @@ -20,9 +20,12 @@ MEDIA_BUCKET=$(stack_output MediaBucketName) AUTO_SCALING_GROUP=$(stack_output AutoScalingGroupName) REGISTRY=${API_REPOSITORY%/*} -for model in retinaface-r50.onnx adaface-ir101-ms1mv2.onnx; do - if [ ! -f "$PROJECT_ROOT/models/onnx/$model" ]; then - echo "Missing required model: models/onnx/$model" >&2 +DETECTION_MODEL="$PROJECT_ROOT/face-processing/models/detection/retinaface-r50.onnx" +RECOGNITION_MODEL="$PROJECT_ROOT/face-processing/models/recognition/adaface-ir101-ms1mv2.onnx" + +for model in "$DETECTION_MODEL" "$RECOGNITION_MODEL"; do + if [ ! -f "$model" ]; then + echo "Missing required model: ${model#"$PROJECT_ROOT"/}" >&2 exit 1 fi done @@ -31,14 +34,14 @@ aws ecr get-login-password --region "$AWS_DEPLOY_REGION" | docker login --userna docker build -f "$PROJECT_ROOT/backend/Dockerfile" -t "$API_REPOSITORY:latest" "$PROJECT_ROOT" docker build -f "$PROJECT_ROOT/webapp/Dockerfile" -t "$WEB_REPOSITORY:latest" "$PROJECT_ROOT" -docker build -f "$PROJECT_ROOT/face-processing/ml/Dockerfile" -t "$ML_REPOSITORY:latest" "$PROJECT_ROOT" +docker build -f "$PROJECT_ROOT/face-processing/service/Dockerfile" -t "$ML_REPOSITORY:latest" "$PROJECT_ROOT" docker push "$API_REPOSITORY:latest" docker push "$WEB_REPOSITORY:latest" docker push "$ML_REPOSITORY:latest" aws s3 cp "$PROJECT_ROOT/docker-compose.aws.yml" "s3://$MEDIA_BUCKET/deployments/docker-compose.aws.yml" --region "$AWS_DEPLOY_REGION" -aws s3 cp "$PROJECT_ROOT/models/onnx/retinaface-r50.onnx" "s3://$MEDIA_BUCKET/models/onnx/retinaface-r50.onnx" --region "$AWS_DEPLOY_REGION" -aws s3 cp "$PROJECT_ROOT/models/onnx/adaface-ir101-ms1mv2.onnx" "s3://$MEDIA_BUCKET/models/onnx/adaface-ir101-ms1mv2.onnx" --region "$AWS_DEPLOY_REGION" +aws s3 cp "$DETECTION_MODEL" "s3://$MEDIA_BUCKET/face-processing/models/detection/retinaface-r50.onnx" --region "$AWS_DEPLOY_REGION" +aws s3 cp "$RECOGNITION_MODEL" "s3://$MEDIA_BUCKET/face-processing/models/recognition/adaface-ir101-ms1mv2.onnx" --region "$AWS_DEPLOY_REGION" aws autoscaling start-instance-refresh \ --auto-scaling-group-name "$AUTO_SCALING_GROUP" \ diff --git a/docker-compose.aws.yml b/docker-compose.aws.yml index b724553..bd7fd73 100644 --- a/docker-compose.aws.yml +++ b/docker-compose.aws.yml @@ -25,7 +25,7 @@ services: FDX_DEVICE: cpu ML_WARMUP_ON_IMPORT: "1" volumes: - - /opt/fdx/models:/app/models:ro + - /opt/fdx/face-processing/models:/app/models:ro healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3000/healthcheck', timeout=3)"] interval: 15s diff --git a/docker-compose.yml b/docker-compose.yml index 8910004..41d0727 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,14 +54,14 @@ services: ml: build: context: . - dockerfile: face-processing/ml/Dockerfile + dockerfile: face-processing/service/Dockerfile environment: ML_PORT: 3000 ML_HOST: 0.0.0.0 MODELS_ROOT: /app/models FDX_DEVICE: ${FDX_DEVICE:-cpu} volumes: - - ./models:/app/models:ro + - ./face-processing/models:/app/models:ro healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3000/healthcheck', timeout=2)"] interval: 15s diff --git a/face-processing/models/MANIFEST.sha256 b/face-processing/models/MANIFEST.sha256 new file mode 100644 index 0000000..91154f9 --- /dev/null +++ b/face-processing/models/MANIFEST.sha256 @@ -0,0 +1,2 @@ +c594643ebe011c2534dd870d4abb0635ec27ce58e50b53f40b8d888a395e575e face-processing/models/recognition/adaface-ir101-ms1mv2.onnx +a607583ad9913b3a54f1b750752ae3f451fe324777df5542921e4b0b8e596a87 face-processing/models/detection/retinaface-r50.onnx diff --git a/face-processing/models/detection/README.md b/face-processing/models/detection/README.md new file mode 100644 index 0000000..d548452 --- /dev/null +++ b/face-processing/models/detection/README.md @@ -0,0 +1,3 @@ +# Face detection model + +Place `retinaface-r50.onnx` here. RetinaFace locates faces and facial landmarks before recognition. diff --git a/face-processing/models/recognition/README.md b/face-processing/models/recognition/README.md new file mode 100644 index 0000000..08a63be --- /dev/null +++ b/face-processing/models/recognition/README.md @@ -0,0 +1,3 @@ +# Face recognition model + +Place `adaface-ir101-ms1mv2.onnx` here. AdaFace generates identity embeddings for face matching. diff --git a/face-processing/ml/Dockerfile b/face-processing/service/Dockerfile similarity index 81% rename from face-processing/ml/Dockerfile rename to face-processing/service/Dockerfile index 0751691..314e5c6 100644 --- a/face-processing/ml/Dockerfile +++ b/face-processing/service/Dockerfile @@ -3,8 +3,8 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 FDX_DEVICE=cpu ML_WARMUP_ON_IMPORT=1 WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* -COPY face-processing/ml/requirements-local.txt /app/requirements.txt +COPY face-processing/service/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt onnxruntime==1.21.1 COPY tools/native_accurate_backend.py /app/tools/native_accurate_backend.py -COPY face-processing/ml/assets /app/face-processing/ml/assets +COPY face-processing/service/assets /app/face-processing/service/assets CMD ["gunicorn", "--bind", "0.0.0.0:3000", "--workers", "1", "--threads", "4", "--timeout", "300", "--graceful-timeout", "30", "--access-logfile", "-", "--error-logfile", "-", "tools.native_accurate_backend:app"] diff --git a/face-processing/ml/assets/warmup/einstein.jpeg b/face-processing/service/assets/warmup/einstein.jpeg similarity index 100% rename from face-processing/ml/assets/warmup/einstein.jpeg rename to face-processing/service/assets/warmup/einstein.jpeg diff --git a/face-processing/ml/requirements-local.txt b/face-processing/service/requirements.txt similarity index 100% rename from face-processing/ml/requirements-local.txt rename to face-processing/service/requirements.txt diff --git a/run-platform.sh b/run-platform.sh index 0eb0386..8e57cd3 100755 --- a/run-platform.sh +++ b/run-platform.sh @@ -4,7 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$repo_root" -if [[ ! -f models/onnx/retinaface-r50.onnx || ! -f models/onnx/adaface-ir101-ms1mv2.onnx ]]; then +if [[ ! -f face-processing/models/detection/retinaface-r50.onnx || ! -f face-processing/models/recognition/adaface-ir101-ms1mv2.onnx ]]; then echo "FDX model files are missing. Run ./tools/verify_models.sh for details." >&2 exit 1 fi diff --git a/tools/native_accurate_backend.py b/tools/native_accurate_backend.py index a1fc779..e65c5da 100755 --- a/tools/native_accurate_backend.py +++ b/tools/native_accurate_backend.py @@ -19,10 +19,10 @@ from flask import Flask, jsonify, request ROOT = Path(__file__).resolve().parents[1] -MODEL_ROOT = Path(os.environ.get("MODELS_ROOT", ROOT / "models")) -DETECTOR_MODEL = MODEL_ROOT / "onnx" / "retinaface-r50.onnx" -CALCULATOR_MODEL = MODEL_ROOT / "onnx" / "adaface-ir101-ms1mv2.onnx" -WARMUP_IMAGE = ROOT / "face-processing" / "ml" / "assets" / "warmup" / "einstein.jpeg" +MODEL_ROOT = Path(os.environ.get("MODELS_ROOT", ROOT / "face-processing" / "models")) +DETECTOR_MODEL = MODEL_ROOT / "detection" / "retinaface-r50.onnx" +RECOGNITION_MODEL = MODEL_ROOT / "recognition" / "adaface-ir101-ms1mv2.onnx" +WARMUP_IMAGE = ROOT / "face-processing" / "service" / "assets" / "warmup" / "einstein.jpeg" PORT = int(os.environ.get("ML_PORT", "3000")) IMAGE_LENGTH_LIMIT = int(os.environ.get("IMG_LENGTH_LIMIT", "1280")) DEVICE = os.environ.get("FDX_DEVICE", "auto").strip().lower() @@ -98,7 +98,7 @@ def _create_session(model_path: Path, providers): _preload_nvidia_libraries() REQUESTED_PROVIDERS = _provider_order() DETECTOR_SESSION = _create_session(DETECTOR_MODEL, REQUESTED_PROVIDERS) -CALCULATOR_SESSION = _create_session(CALCULATOR_MODEL, REQUESTED_PROVIDERS) +RECOGNITION_SESSION = _create_session(RECOGNITION_MODEL, REQUESTED_PROVIDERS) ACTIVE_PROVIDER = DETECTOR_SESSION.get_providers()[0] if DEVICE in {"gpu", "cuda"} and ACTIVE_PROVIDER != "CUDAExecutionProvider": @@ -394,8 +394,8 @@ def _embedding(face: np.ndarray) -> tuple[np.ndarray, float]: input_tensor = input_tensor / 127.5 - 1.0 flip_tensor = input_tensor[:, :, :, ::-1].copy() batch = np.concatenate((input_tensor, flip_tensor), axis=0) - embeddings, norms = CALCULATOR_SESSION.run( - None, {CALCULATOR_SESSION.get_inputs()[0].name: batch} + embeddings, norms = RECOGNITION_SESSION.run( + None, {RECOGNITION_SESSION.get_inputs()[0].name: batch} ) norms = np.asarray(norms, dtype=np.float32).reshape(-1, 1) weights = norms / max(float(norms.sum()), np.finfo(np.float32).eps) diff --git a/tools/verify_models.sh b/tools/verify_models.sh index 18d1425..614fe9f 100755 --- a/tools/verify_models.sh +++ b/tools/verify_models.sh @@ -4,9 +4,9 @@ set -eu ROOT_DIR=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) cd "$ROOT_DIR" -if [ ! -f models/MANIFEST.sha256 ]; then - printf '%s\n' "Missing models/MANIFEST.sha256" >&2 +if [ ! -f face-processing/models/MANIFEST.sha256 ]; then + printf '%s\n' "Missing face-processing/models/MANIFEST.sha256" >&2 exit 1 fi -sha256sum --check models/MANIFEST.sha256 +sha256sum --check face-processing/models/MANIFEST.sha256 From 30f38212781d63ede60a25975130ce512dbde51d Mon Sep 17 00:00:00 2001 From: Dijo S Benelen Date: Thu, 13 Aug 2026 00:07:07 +0530 Subject: [PATCH 2/4] docs: convert workflow specification to markdown --- README.md | 2 +- docs/workflow.md | 645 +++++++++++++++++++++++++++++++++++ docs/workflow.png | Bin 0 -> 420794 bytes docs/workflow.txt | 834 ---------------------------------------------- 4 files changed, 646 insertions(+), 835 deletions(-) create mode 100644 docs/workflow.md create mode 100644 docs/workflow.png delete mode 100644 docs/workflow.txt diff --git a/README.md b/README.md index 2c11c40..6a22662 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FDX -FDX is a multi-tenant event-photo delivery platform implementing the workflow in `docs/workflow.txt`. A single JWT login routes Super Admins, Organization Admins, and restricted Staff users to role-scoped React dashboards. +FDX is a multi-tenant event-photo delivery platform implementing the workflow in [`docs/workflow.md`](docs/workflow.md). A single JWT login routes Super Admins, Organization Admins, and restricted Staff users to role-scoped React dashboards. ## Architecture diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..b2cb5ee --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,645 @@ +# FDX Product Workflow and System Architecture + +This document is the source of truth for the FDX product workflow. FDX is a multi-tenant event-photo delivery platform in which an organization imports event participants, securely enrolls their faces, processes event photographs, and privately delivers matched photographs. + +The original architecture sketch is retained for historical and visual reference: + +![Original FDX architecture sketch](workflow.png) + +The diagrams below normalize the sketch around one login, role-based access, organization tenancy, asynchronous processing, and private delivery without removing any of the original workflow requirements. + +## 1. Overall FDX workflow + +FDX has one login system. There are not separate Admin Login and College Login systems. Authentication issues a JWT/session, resolves the user's role, and routes the user to the correct role-protected frontend area. + +```mermaid +flowchart TD + FDX[FDX] --> Login[Single Login] + Login --> Auth[Authentication] + Auth --> JWT[JWT / Session Issued] + JWT --> Role{Resolve Role} + Role -->|super_admin| Admin[FDX Management
/admin] + Role -->|org_admin| Org[College / Company Management
/organization] + Role -->|staff| Staff[Restricted Organization Workspace
/organization] +``` + +The frontend already follows this model through role-protected routes. The canonical routing rule is: + +```text +One Login +├── role = super_admin → /admin +├── role = org_admin → /organization +└── role = staff → /organization (permission restricted) +``` + +## 2. Super Admin workflow + +The Super Admin manages FDX itself rather than individual events. + +```mermaid +flowchart LR + Login[FDX Login] --> Credentials[Email + Password] + Credentials --> Backend[Backend Authentication] + Backend --> JWT[JWT Issued] + JWT --> Role[role = super_admin] + Role --> Dashboard[Admin Dashboard] +``` + +The Admin Dashboard should expose: + +- Total organizations +- Total organization users +- Total events +- Total photos +- Total storage used +- Processing jobs +- Emails sent +- Failed jobs +- Expiring data +- System health + +## 3. Organization management + +The Super Admin creates and manages colleges or companies. Do not maintain separate `College` and `Company` tables. Use a single `Organization` entity with a type discriminator: + +```text +Organization +├── type = COLLEGE +└── type = COMPANY +``` + +The organization record contains at least: + +| Field | Purpose | +| --- | --- | +| `id` | Organization identifier | +| `name` | College or company name | +| `type` | `COLLEGE` or `COMPANY` | +| `email` | Primary contact email | +| `storage_limit` | Allocated storage quota | +| `storage_used` | Current accounted usage | +| `retention_days` | Default retention policy | +| `status` | Active, suspended, or expired state | +| `created_at` | Creation timestamp | +| `expires_at` | Optional account/subscription expiry | + +```mermaid +flowchart TD + Admin[Super Admin] --> Organizations[Organizations] + Organizations --> Create[Create Organization] + Create --> Details[Enter name, type, contacts,
storage quota, retention, expiry] + Details --> Save[Create] + Save --> Active[Organization Becomes Active] +``` + +## 4. Organization user creation + +After creating an organization, the Super Admin creates its administrator: + +```mermaid +flowchart TD + Admin[Super Admin] --> Org[Organization] + Org --> Create[Create Organization Admin] + Create --> Details[Name + Email + Organization + Role] + Details --> Account[Account Created] + Account --> Invite[Invite Email Sent] + Invite --> Password[User Sets Password] +``` + +Example organization membership: + +```text +CIT +├── user1@cit.edu → Org Admin +├── user2@cit.edu → Org Admin +└── user3@cit.edu → Staff +``` + +The minimum role model is `super_admin` and `org_admin`. Staff can be introduced as a restricted organization role; when enabled, it must be permission-scoped rather than equivalent to an Organization Admin. + +## 5. Storage and expiry management + +Storage quota, retention, and account expiry are primarily controlled by the FDX Super Admin. + +Example organization summary: + +| Metric | Example | +| --- | --- | +| Organization | College A | +| Storage | 120 GB / 200 GB | +| Retention | 90 days | +| Events | 28 | +| Next expiry | 24 Aug 2026 | + +The Admin can configure: + +- Storage limit: 100 GB, 500 GB, 1 TB, or another configured value. +- Retention policy: 30, 60, 90, 180, or custom days. +- Account expiry: optional subscription expiry. + +A scheduled backend job enforces event-data expiry: + +```mermaid +flowchart TD + Schedule[Scheduled Retention Job] --> Data[Load Event Data] + Data --> Expired{expiry_date reached?} + Expired -->|No| Keep[Keep Data] + Expired -->|Yes| Mark[Mark Event Expired] + Mark --> Originals[Remove Event Photos] + Originals --> Derived[Remove Thumbnails and Derived Face Data] + Derived --> Usage[Update Storage Usage] + Usage --> Audit[Write Audit Log] +``` + +## 6. College / Company workflow and tenant isolation + +When an Organization Admin logs in, the JWT determines both the role and the organization tenant: + +```text +Login +→ JWT +→ role = org_admin +→ organization_id determined +→ Organization Dashboard +``` + +Tenant isolation is mandatory. If a logged-in user belongs to `organization_id = CIT`, every organization-scoped backend query must effectively include: + +```sql +WHERE organization_id = 'CIT' +``` + +An organization user must never be able to access another organization's events, users, participants, photographs, matches, deliveries, or logs. Authorization must be enforced by the backend, not only hidden in the frontend. + +## 7. Organization dashboard + +The Organization Dashboard should be organized around: + +- Dashboard +- Events +- Participants +- Uploads +- Processing +- Face Matches +- Deliveries +- Logs +- Settings + +The frontend areas previously called College Dashboard, Upload Data, Students, Events, Face Detection Data, and Logs evolve into this organization-neutral structure rather than requiring a complete redesign. Use **Participants**, not **Students**, so FDX works for both colleges and companies. + +## 8. Event creation workflow + +Event creation begins the main FDX business workflow. + +```mermaid +flowchart TD + Admin[Organization Admin] --> Events[Events] + Events --> Create[Create Event] + Create --> Details[Name + Description + Date
Location + Retention Period] + Details --> Save[Create] + Save --> ID[Event ID Generated] + ID --> Preparing[Status = Preparing] +``` + +Example event: + +| Field | Value | +| --- | --- | +| Event | GDG DevFest 2026 | +| Organization | Chennai Institute of Technology | +| Date | 12 August 2026 | +| Status | Preparing | + +## 9. Participant upload + +The organization imports the people who attended the event from CSV or Excel. An example `participants.csv` is: + +```csv +Name,Email +Dijo,dijo@example.com +John,john@example.com +Alex,alex@example.com +``` + +```mermaid +flowchart LR + Event[Event] --> Upload[Upload Participants] + Upload --> File[CSV / XLS / XLSX / XLSM] + File --> Validate[Backend Validation] + Validate --> Duplicates[Check Duplicates] + Duplicates --> Store[Store Participants] +``` + +Each participant record contains approximately: + +| Field | Purpose | +| --- | --- | +| `id` | Participant identifier | +| `event_id` | Parent event | +| `organization_id` | Tenant boundary | +| `name` | Participant name | +| `email` | Delivery and enrollment email | +| `enrollment_status` | Invitation/consent/enrollment state | +| `delivery_status` | Gallery delivery state | + +## 10. Face enrollment email + +After participants are imported, FDX creates an individual secure enrollment link and sends it by email. + +```mermaid +flowchart LR + Participant[Participant] --> Token[Generate Secure Enrollment Token] + Token --> URL[Generate Unique URL] + URL --> Email[Send Enrollment Email] +``` + +Example link: + +```text +https://fdx.app/enroll/x82hd82ks9 +``` + +Example email content: + +> Photos from GDG DevFest are being processed. To find photographs containing you, please verify your face using the secure link below. **Find My Photos** + +Development can use the persistent email outbox. Production delivery uses Resend or AWS SES credentials. + +## 11. Attendee face capture workflow + +The participant does not need an FDX account. The secure enrollment token authorizes only the intended enrollment flow. + +```mermaid +flowchart TD + Email[Enrollment Email] --> Link[Secure Link] + Link --> Page[Participant Verification Page] + Page --> Camera[Camera Permission] + Camera --> Selfie[Take Selfie] + Selfie --> Confirm[Confirm Photo] + Confirm --> Consent[Record Consent] + Consent --> Submit[Submit] + Submit --> Detect[RetinaFace Detection] + Detect --> Align[Face Alignment] + Align --> Embed[AdaFace Embedding] + Embed --> Vector[512-dimensional Embedding] + Vector --> Store[Store Securely] +``` + +The current implementation uses RetinaFace R50 for detection and AdaFace IR101 for identity matching through ONNX Runtime. This workflow builds on that recognition pipeline rather than replacing it. + +## 12. Event photo upload + +The Organization Admin uploads the photographer's event folder as files, a browser-selected folder, a ZIP archive, or a batch upload. + +Example folder: + +```text +DevFest2026/ +├── IMG_001.jpg +├── IMG_002.jpg +├── IMG_003.jpg +├── IMG_004.jpg +└── ... +``` + +```mermaid +flowchart LR + Admin[Organization Admin] --> Event[Event] + Event --> Upload[Upload Event Photos] + Upload --> Input[Files / Folder / ZIP / Batch] + Input --> Service[Upload Service] + Service --> Storage[Private Object Storage] + Service --> Metadata[Database Metadata] +``` + +The database stores metadata such as `event_id`, `photo_id`, `storage_key`, `upload_time`, and `processing_status`. Image objects normally live in object storage: + +```text +organizations/ +└── org_001/ + └── events/ + └── event_023/ + ├── original/ + └── thumbnails/ +``` + +Originals and thumbnails must be served securely rather than exposed as public objects. + +## 13. Processing pipeline + +When upload completes, FDX creates asynchronous processing jobs. Kafka is the primary job transport, with worker-side processing and durable application state. + +```mermaid +flowchart TD + Upload[Upload Complete] --> Jobs[Create Processing Jobs] + Jobs --> Kafka[Kafka / Queue] + Kafka --> Worker[ML Worker] + Worker --> Photo[Load Photo] + Photo --> Retina[RetinaFace] + Retina --> Faces[Detect Faces] + Faces --> Align[Crop + Align] + Align --> Ada[AdaFace] + Ada --> Embeddings[Generate Face Embeddings] + Embeddings --> Compare[Compare with Enrolled Participants] + Compare --> Score[Similarity Score] +``` + +The repository's existing RetinaFace → AdaFace recognition path and cosine-similarity matching directly implement this architecture. + +## 14. Face matching + +Participant enrollment embeddings are compared with every detected face embedding in the same event. + +```mermaid +flowchart TD + Selfie[Participant Selfie] --> ParticipantEmbedding[Participant Embedding] + EventPhoto[Event Photo] --> Detect[Detect Faces] + Detect --> FaceEmbeddings[Face Embeddings] + ParticipantEmbedding --> Compare[Cosine Similarity Comparison] + FaceEmbeddings --> Compare + Compare --> Decision{Meets confidence policy?} + Decision -->|Match| Assign[Assign Photo to Participant] + Decision -->|No match| Unknown[Unknown Face] +``` + +The output can associate many photos with one participant: + +```text +Dijo +├── IMG_001.jpg +├── IMG_018.jpg +├── IMG_052.jpg +└── IMG_103.jpg + +John +├── IMG_002.jpg +├── IMG_010.jpg +└── IMG_088.jpg +``` + +Multiple participants can naturally appear in the same photograph, so a photo can have multiple participant matches. + +## 15. Confidence handling + +Do not immediately accept every similarity result. Use three confidence states: + +```mermaid +flowchart LR + Score[Similarity Score] --> Policy{Confidence Band} + Policy -->|High| Auto[Automatically Match] + Policy -->|Medium| Review[Review / Stricter Verification] + Policy -->|Low| Unknown[Unknown] +``` + +The matching configuration is deliberately conservative. Similarity thresholds must be calibrated with actual camera and event data. + +## 16. Results dashboard + +An event dashboard should summarize upload, enrollment, processing, matching, and delivery. Example metrics: + +| Metric | Example value | +| --- | ---: | +| Photos uploaded | 3,482 | +| Faces detected | 8,920 | +| Participants | 672 | +| Faces submitted | 601 | +| Participants matched | 574 | +| Unmatched | 27 | +| Emails delivered | 548 | +| Processing | 26 | + +The event advances through: + +```mermaid +flowchart LR + Uploaded --> Processing --> Matching --> Ready --> Delivered +``` + +Event-level results may be represented across dashboard pages, but together they must expose the complete event state and the metrics above. + +## 17. Sending photos to participants + +When processing finishes and matches are approved, FDX creates a private gallery and emails an expiring access link. + +```mermaid +flowchart TD + Participant[Participant] --> Found[Matched Photos Found] + Found --> Gallery[Create Private Gallery] + Gallery --> URL[Generate Expiring Signed URL] + URL --> Email[Send Result Email] + Email --> Open[View My Photos] + Open --> Secure[Secure FDX Gallery] + Secure --> Selected[Download Selected] + Secure --> All[Download All] +``` + +Prefer a gallery over attaching many images to an email because it scales better for large events. The participant sees a private grid of matched photographs with download-selected and download-all actions. + +The gallery link can expire after 7 days, 30 days, or at the event retention deadline, according to policy. Originals and thumbnails remain protected behind authorized or signed access. + +## 18. Full participant workflow + +This is the primary end-to-end product sequence. Participant enrollment and event-photo upload can happen independently; ML processing joins them before delivery. + +```mermaid +sequenceDiagram + actor Admin as Organization Admin + participant API as FDX API + participant Email as Email Service + actor Attendee as Participant + participant Storage as Object Storage + participant Queue as Kafka / Queue + participant ML as ML Worker + + Admin->>API: Create event + Admin->>API: Upload participant list (name + email) + API->>Email: Send secure enrollment invitation + Email-->>Attendee: Enrollment link + Attendee->>API: Consent and submit selfie + API->>ML: Detect, align, and embed selfie + ML-->>API: Store participant embedding + Admin->>API: Upload event photos + API->>Storage: Store originals and thumbnails + API->>Queue: Create processing jobs + Queue->>ML: Process event photos + ML->>ML: Detect faces and generate embeddings + ML->>API: Store confidence-scored matches + API->>Email: Send private gallery result + Email-->>Attendee: Expiring gallery link + Attendee->>API: View or download matched photos +``` + +## 19. Recommended layered architecture + +The system is easiest to understand as horizontal layers instead of placing Redis, email, Kafka, ML, Docker, and AWS at the same architectural level. + +```mermaid +flowchart TB + subgraph Identity[Authentication and Authorization] + Login[Single Login] --> Session[JWT / Session] + Session --> Resolver{Role Resolver} + Resolver --> SA[Super Admin] + Resolver --> OA[Organization Admin] + Resolver --> ST[Restricted Staff] + end + + subgraph Frontends[React + Vite Frontends] + AdminUI[Admin
Dashboard · Organizations · Users
Storage · Retention · System Logs] + OrgUI[Organization
Dashboard · Events · Participants
Uploads · Processing · Matches
Deliveries · Logs · Settings] + PublicUI[Token-scoped Participant
Enrollment · Private Gallery] + end + + SA --> AdminUI + OA --> OrgUI + ST --> OrgUI + SecureToken[Enrollment / Gallery Token] --> PublicUI + + AdminUI --> Nginx + OrgUI --> Nginx + PublicUI --> Nginx + + subgraph Edge[Edge and Routing] + Nginx[NGINX Reverse Proxy
Routing · Upload Limits · Rate Limiting] + end + + Nginx --> API + + subgraph Application[FastAPI Application] + API[Auth · Organizations · Users · Events
Participants · Uploads · Galleries
Notifications · Retention · Audit Logs] + Worker[Background Worker] + end + + API --> Postgres[(PostgreSQL
Source of Truth)] + API --> Redis[(Redis
Rate Limits + Health Cache)] + API --> Kafka[(Kafka
Processing Jobs)] + Kafka --> Worker + + subgraph FaceProcessing[Face Processing] + Worker --> Detect[RetinaFace Detection] + Detect --> Align[Crop + Align] + Align --> Recognize[AdaFace Embedding] + Recognize --> Match[Confidence-scored Matching] + end + + API --> Storage[(Private S3 / Object Storage
Originals + Thumbnails)] + Worker --> Storage + API --> Mail[Resend / AWS SES
Enrollment + Result Email] + API --> Retention[Scheduled Retention
Expiry + Cleanup Jobs] + + subgraph Runtime[Runtime and Deployment] + Docker[Docker Containers] + AWS[AWS
ALB · EC2 · RDS · ElastiCache
MSK · S3/Glacier · SES · Lambda · IAM] + Docker --> AWS + end +``` + +### Layer responsibilities + +| Layer | Responsibility | +| --- | --- | +| Authentication | One login, JWT/session issuance, role and tenant resolution | +| Admin frontend | Global platform, organization, quota, retention, and health management | +| Organization frontend | Events, participants, uploads, processing, matches, deliveries, logs, and settings | +| NGINX | Routing, upload controls, reverse proxying, and rate limiting | +| FastAPI | Auth, tenancy, requests, metadata, orchestration, and secure media endpoints | +| PostgreSQL | Authoritative application state and durable job fallback | +| Redis | Login rate limiting and health caching | +| Kafka | Asynchronous event-photo processing jobs | +| Face processing | RetinaFace detection, alignment, AdaFace embeddings, and matching | +| External services | Private object storage, email delivery, retention, and cleanup | +| Deployment | Docker locally and the AWS production infrastructure stack | + +## 20. Required terminology and diagram corrections + +The old conceptual flow: + +```text +FDX → JWT → Admin Login / College Login +``` + +must be represented as: + +```text +FDX → Single Login → Authentication → JWT → Role Resolution + ├── SUPER_ADMIN + ├── ORG_ADMIN + └── STAFF (restricted) +``` + +Apply these terminology rules everywhere: + +- Replace **College Login** with **Organization Dashboard — College / Company**. +- Replace **Students** with **Participants**. +- Use **Organization** as the backend, API, JWT, storage, Kafka, and permission concept. +- The frontend may display **College** or **Company** according to `organization.type`. + +## 21. Permission model + +The clean permission model is: + +| Function | Super Admin | Organization Admin | Staff | +| --- | :---: | :---: | :---: | +| Login | ✓ | ✓ | ✓ | +| Create organizations | ✓ | — | — | +| Delete/suspend organizations | ✓ | — | — | +| Set storage quotas | ✓ | — | — | +| Set retention policy | ✓ | — | — | +| Create organization admins | ✓ | — | — | +| View global system stats | ✓ | — | — | +| Create events | — | ✓ | Permission-scoped | +| Upload participants | — | ✓ | Permission-scoped | +| Upload event photos | — | ✓ | ✓ when granted | +| Process faces | — | ✓ | ✓ when granted | +| View matches | — | ✓ | Read-only when granted | +| Send participant emails | — | ✓ | — unless granted | +| View organization logs | Limited/global | ✓ | Read-only when granted | +| Delete event | Optional | ✓ | — | + +Staff permissions are optional extensions to the minimum `super_admin`/`org_admin` model. Every permitted Staff action remains tenant-scoped. + +## 22. Core database structure + +FDX does not need dozens of entities to express its core domain. The principal relationship is: + +```mermaid +erDiagram + ORGANIZATION ||--o{ USER : has + ORGANIZATION ||--o{ EVENT : owns + EVENT ||--o{ PARTICIPANT : includes + PARTICIPANT ||--o| FACE_ENROLLMENT : submits + EVENT ||--o{ PHOTO : contains + PHOTO ||--o{ FACE_DETECTION : produces + PARTICIPANT ||--o{ FACE_MATCH : receives + PHOTO ||--o{ FACE_MATCH : appears_in + PARTICIPANT ||--o{ DELIVERY : receives + EVENT ||--o{ PROCESSING_JOB : schedules + ORGANIZATION ||--o{ AUDIT_LOG : records +``` + +Core relational tables: + +```text +organizations +users +events +participants +face_enrollments +photos +face_detections +face_matches +deliveries +audit_logs +processing_jobs +``` + +All tenant-owned records must retain an organization relationship directly or through a securely validated parent relationship. + +## 23. FDX business workflow in one sentence + +> An organization creates an event → uploads attendee identities and event photographs → FDX securely enrolls attendees' faces → processes the event gallery → identifies each attendee across the photographs → privately delivers each attendee only the photographs containing them. + +The Super Admin sits one level above this workflow and manages organizations, users, storage, retention policies, account expiry, auditability, and platform health. + +The architecture fits the existing codebase: React/Vite provides role-scoped Super Admin and Organization dashboards; FastAPI owns authentication and workflow orchestration; PostgreSQL, Redis, and Kafka provide persistence, caching/rate limiting, and job delivery; RetinaFace/AdaFace provides local face processing; private storage and email services provide delivery; Docker and AWS provide runtime and deployment. + +The essential architectural rule is to remove **College** as a first-class backend concept and use **Organization** everywhere. College or Company is presentation determined by `organization.type`; APIs, database schemas, JWT claims, storage keys, Kafka messages, and permissions stay identical for both. diff --git a/docs/workflow.png b/docs/workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..febf59275e6cf4b194efa7ad1ee574e9e4c46030 GIT binary patch literal 420794 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

1r_;W;;8&P{( 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 Date: Thu, 13 Aug 2026 00:57:11 +0530 Subject: [PATCH 3/4] feat: implement FDX V2 technical specification --- .env.example | 24 + .github/workflows/ci.yml | 80 + README.md | 12 +- backend/alembic.ini | 4 +- .../versions/20260813_03_v2_foundation.py | 92 + .../versions/20260813_04_gallery_exports.py | 24 + backend/app/auth.py | 86 +- backend/app/config.py | 22 +- backend/app/integrations.py | 40 +- backend/app/main.py | 185 +- backend/app/models.py | 229 + backend/app/v2.py | 1712 ++++++ backend/app/worker.py | 324 +- backend/requirements.txt | 10 +- backend/tests/test_security.py | 58 + deploy/nginx/default.conf | 21 + docker-compose.yml | 16 +- docs/spec-implementation.md | 48 + docs/specs.md | 4642 +++++++++++++++++ tools/verify_v2.mjs | 190 + webapp/src/App.jsx | 4 + webapp/src/components/DashboardShell.jsx | 4 +- webapp/src/components/ProtectedRoute.jsx | 4 +- webapp/src/context/AuthContext.jsx | 24 +- webapp/src/context/PlatformContext.jsx | 54 +- webapp/src/index.css | 3 +- webapp/src/lib/api.js | 129 +- webapp/src/pages/Login.jsx | 8 +- .../src/pages/organization/Participants.jsx | 23 +- webapp/src/pages/public/AcceptInvite.jsx | 4 +- webapp/src/pages/public/Enrollment.jsx | 17 +- webapp/src/pages/public/ForgotPassword.jsx | 38 + webapp/src/pages/public/Gallery.jsx | 115 +- webapp/src/pages/public/ResetPassword.jsx | 43 + 34 files changed, 8139 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/alembic/versions/20260813_03_v2_foundation.py create mode 100644 backend/alembic/versions/20260813_04_gallery_exports.py create mode 100644 backend/app/v2.py create mode 100644 backend/tests/test_security.py create mode 100644 docs/spec-implementation.md create mode 100644 docs/specs.md create mode 100644 tools/verify_v2.mjs create mode 100644 webapp/src/pages/public/ForgotPassword.jsx create mode 100644 webapp/src/pages/public/ResetPassword.jsx diff --git a/.env.example b/.env.example index ccb754c..77f9697 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,13 @@ POSTGRES_PASSWORD=replace-with-a-long-random-password JWT_SECRET=replace-with-at-least-32-random-bytes +JWT_ISSUER=fdx +JWT_AUDIENCE=fdx-web +ACCESS_TOKEN_MINUTES=15 +REFRESH_TOKEN_DAYS=7 +INVITATION_TOKEN_HOURS=72 +PASSWORD_RESET_MINUTES=30 +ENROLLMENT_TOKEN_DAYS=7 +GALLERY_TOKEN_DAYS=7 FDX_SUPER_ADMIN_EMAIL=superadmin@fdx.io FDX_SUPER_ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password FDX_WEB_PORT=8080 @@ -15,8 +23,24 @@ FDX_ENVIRONMENT=development EMAIL_PROVIDER=outbox EMAIL_FROM=FDX RESEND_API_KEY= +EMAIL_WEBHOOK_SECRET= # Object storage: local or s3 STORAGE_BACKEND=local S3_BUCKET= AWS_REGION=ap-south-1 + +# ML matching policy and model traceability +FDX_DETECTOR_MODEL_VERSION=retinaface-r50-v1 +FDX_EMBEDDER_MODEL_VERSION=adaface-ir101-ms1mv2-v1 +MATCH_AUTO_THRESHOLD=0.85 +MATCH_REVIEW_THRESHOLD=0.65 +MATCH_RUNNER_UP_MARGIN=0.08 +THRESHOLD_PROFILE_VERSION=default-v1 + +# Workflow policy +CONSENT_POLICY_VERSION=2026-08-13 +UPLOAD_RESERVATION_MINUTES=60 +MAX_UPLOAD_BYTES=107374182400 +RETENTION_SCHEDULER_ENABLED=true +RETENTION_POLL_SECONDS=60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f7d6726 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: FDX CI + +on: + pull_request: + push: + branches: [main, feature/scale] + +permissions: + contents: read + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: webapp + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: webapp/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run build + - run: npm audit --audit-level=high + + backend: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:0.8.6-pg17 + env: + POSTGRES_DB: fdx + POSTGRES_USER: fdx + POSTGRES_PASSWORD: fdx-ci + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U fdx -d fdx" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + DATABASE_URL: postgresql+psycopg://fdx:fdx-ci@127.0.0.1:5432/fdx + FDX_ENVIRONMENT: test + PYTHONPATH: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + - run: pip install -r backend/requirements.txt ruff pytest pip-audit + - run: ruff check --select F,E9 backend + - run: python -m compileall -q backend/app backend/alembic + - run: alembic -c backend/alembic.ini upgrade head + - run: pytest -q backend/tests + - run: pip-audit -r backend/requirements.txt + + infrastructure: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install cfn-lint + - run: docker compose config --quiet + - run: cfn-lint deploy/aws/platform.yml + - run: bash -n run-platform.sh stop-platform.sh + - run: sh -n deploy/aws/publish.sh tools/verify_models.sh backend/entrypoint.sh + + images: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker build -f backend/Dockerfile -t fdx-api:ci . + - run: docker build -f webapp/Dockerfile -t fdx-web:ci . diff --git a/README.md b/README.md index 6a22662..034509b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FDX -FDX is a multi-tenant event-photo delivery platform implementing the workflow in [`docs/workflow.md`](docs/workflow.md). A single JWT login routes Super Admins, Organization Admins, and restricted Staff users to role-scoped React dashboards. +FDX is a multi-tenant event-photo delivery platform implementing the product workflow in [`docs/workflow.md`](docs/workflow.md) and the V2 technical contract in [`docs/specs.md`](docs/specs.md). A single JWT login routes Super Admins, Organization Admins, and restricted Staff users to role-scoped React dashboards. The implementation map is maintained in [`docs/spec-implementation.md`](docs/spec-implementation.md). ## Architecture @@ -15,6 +15,8 @@ FDX is a multi-tenant event-photo delivery platform implementing the workflow in PostgreSQL is the source of truth, Redis provides login rate limiting and health caching, Kafka distributes processing jobs, and the worker retains a PostgreSQL fallback queue. Development media uses a Docker volume; production media uses private S3 storage with generated thumbnails. +The stable dashboard remains compatible with the original `/api` contract while security-sensitive and high-scale workflows use the additive `/api/v2` contract: rotating refresh sessions, import preview/confirmation, presigned upload batches, processing/review, delivery, and asynchronous gallery exports. + ## Required models Place each ONNX model under the directory matching its role: @@ -75,6 +77,14 @@ FDX_VERIFY_FACE_IMAGE=face-processing/service/assets/warmup/einstein.jpeg \ Set `FDX_VERIFY_XLS=/path/to/participants.xls` to include legacy Excel verification. +Run the V2 acceptance flow (refresh replay prevention, invitations, tenant isolation, import idempotency, direct upload, real ML, private gallery, async ZIP export, and logout revocation): + +```sh +FDX_VERIFY_FACE_IMAGE=/path/to/clear-face.jpg node tools/verify_v2.mjs +``` + +API service metrics are available at `GET /metrics`; dependency probes are exposed at `/health/live`, `/health/ready`, and `/health/dependencies`. + Frontend checks: ```sh diff --git a/backend/alembic.ini b/backend/alembic.ini index 3f299e2..31fc8bf 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -1,6 +1,6 @@ [alembic] -script_location = /app/alembic -prepend_sys_path = /app +script_location = %(here)s/alembic +prepend_sys_path = %(here)s sqlalchemy.url = postgresql+psycopg://fdx:fdx@postgres:5432/fdx [loggers] diff --git a/backend/alembic/versions/20260813_03_v2_foundation.py b/backend/alembic/versions/20260813_03_v2_foundation.py new file mode 100644 index 0000000..4847371 --- /dev/null +++ b/backend/alembic/versions/20260813_03_v2_foundation.py @@ -0,0 +1,92 @@ +"""Add the FDX V2 security, workflow, outbox, and storage foundation.""" + +import sqlalchemy as sa +from alembic import op +from app import models # noqa: F401 +from app.database import Base +from pgvector.sqlalchemy import Vector +from sqlalchemy import inspect + +revision = "20260813_03" +down_revision = "20260812_02" +branch_labels = None +depends_on = None + + +def _add(table: str, name: str, column: sa.Column) -> None: + inspector = inspect(op.get_bind()) + if name not in {item["name"] for item in inspector.get_columns(table)}: + op.add_column(table, column) + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + _add("users", "updated_at", sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())) + + _add("events", "starts_at", sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True)) + _add("events", "ends_at", sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True)) + _add("events", "enrollment_opens_at", sa.Column("enrollment_opens_at", sa.DateTime(timezone=True), nullable=True)) + _add("events", "enrollment_closes_at", sa.Column("enrollment_closes_at", sa.DateTime(timezone=True), nullable=True)) + _add("events", "gallery_expires_at", sa.Column("gallery_expires_at", sa.DateTime(timezone=True), nullable=True)) + _add("events", "created_by", sa.Column("created_by", sa.String(length=36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True)) + + _add("face_enrollments", "organization_id", sa.Column("organization_id", sa.String(length=36), sa.ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True)) + _add("face_enrollments", "embedding_vector", sa.Column("embedding_vector", Vector(512), nullable=True)) + _add("face_enrollments", "event_id", sa.Column("event_id", sa.String(length=36), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=True)) + _add("face_enrollments", "status", sa.Column("status", sa.String(length=24), nullable=False, server_default="valid")) + _add("face_enrollments", "model_name", sa.Column("model_name", sa.String(length=120), nullable=False, server_default="adaface-ir101-ms1mv2")) + _add("face_enrollments", "model_version", sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1")) + _add("face_enrollments", "embedding_dimension", sa.Column("embedding_dimension", sa.Integer(), nullable=False, server_default="512")) + _add("face_enrollments", "quality_score", sa.Column("quality_score", sa.Float(), nullable=True)) + _add("face_enrollments", "expires_at", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True)) + _add("face_enrollments", "deleted_at", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) + + _add("face_detections", "face_index", sa.Column("face_index", sa.Integer(), nullable=False, server_default="0")) + _add("face_detections", "embedding_vector", sa.Column("embedding_vector", Vector(512), nullable=True)) + _add("face_detections", "landmarks", sa.Column("landmarks", sa.JSON(), nullable=True)) + _add("face_detections", "face_width", sa.Column("face_width", sa.Integer(), nullable=True)) + _add("face_detections", "face_height", sa.Column("face_height", sa.Integer(), nullable=True)) + _add("face_detections", "quality_class", sa.Column("quality_class", sa.String(length=24), nullable=False, server_default="GOOD")) + _add("face_detections", "model_name", sa.Column("model_name", sa.String(length=120), nullable=False, server_default="retinaface-r50")) + _add("face_detections", "model_version", sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1")) + + _add("face_matches", "second_best_score", sa.Column("second_best_score", sa.Float(), nullable=True)) + _add("face_matches", "margin", sa.Column("margin", sa.Float(), nullable=True)) + _add("face_matches", "decision_source", sa.Column("decision_source", sa.String(length=24), nullable=False, server_default="AUTO")) + _add("face_matches", "model_name", sa.Column("model_name", sa.String(length=120), nullable=False, server_default="adaface-ir101-ms1mv2")) + _add("face_matches", "model_version", sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1")) + _add("face_matches", "threshold_profile_version", sa.Column("threshold_profile_version", sa.String(length=80), nullable=False, server_default="default-v1")) + + _add("processing_jobs", "attempt", sa.Column("attempt", sa.Integer(), nullable=False, server_default="0")) + _add("processing_jobs", "max_attempts", sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5")) + _add("processing_jobs", "progress_current", sa.Column("progress_current", sa.Integer(), nullable=False, server_default="0")) + _add("processing_jobs", "progress_total", sa.Column("progress_total", sa.Integer(), nullable=False, server_default="100")) + _add("processing_jobs", "correlation_id", sa.Column("correlation_id", sa.String(length=36), nullable=True)) + _add("processing_jobs", "next_attempt_at", sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True)) + _add("processing_jobs", "heartbeat_at", sa.Column("heartbeat_at", sa.DateTime(timezone=True), nullable=True)) + + # New V2 tables are declared centrally in models.py; create_all is safe and + # idempotent here and keeps local PostgreSQL and production migrations aligned. + Base.metadata.create_all(op.get_bind()) + + # create_all creates upload_batches on fresh databases; this guard supports + # an interrupted/partially-applied V2 migration as well. + if "upload_batches" in inspect(op.get_bind()).get_table_names(): + _add("upload_batches", "manifest", sa.Column("manifest", sa.JSON(), nullable=True)) + + op.execute(""" + UPDATE face_enrollments AS enrollment + SET organization_id = participant.organization_id, + event_id = participant.event_id, + expires_at = event.expires_at::timestamp with time zone + FROM participants AS participant, events AS event + WHERE enrollment.participant_id = participant.id + AND participant.event_id = event.id + AND enrollment.organization_id IS NULL + """) + + +def downgrade() -> None: + # The V2 migration is intentionally forward-only because dropping its tables + # would destroy refresh sessions, consent evidence, and audit/outbox state. + pass diff --git a/backend/alembic/versions/20260813_04_gallery_exports.py b/backend/alembic/versions/20260813_04_gallery_exports.py new file mode 100644 index 0000000..4af7131 --- /dev/null +++ b/backend/alembic/versions/20260813_04_gallery_exports.py @@ -0,0 +1,24 @@ +"""Add asynchronous private gallery exports. + +Revision ID: 20260813_04 +Revises: 20260813_03 +""" + +from alembic import op +from app.models import GalleryExport # noqa: F401 +from sqlalchemy import inspect + +revision = "20260813_04" +down_revision = "20260813_03" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if "gallery_exports" not in inspect(op.get_bind()).get_table_names(): + GalleryExport.__table__.create(op.get_bind()) + + +def downgrade() -> None: + # Biometric and media lifecycle migrations are intentionally forward-only. + pass diff --git a/backend/app/auth.py b/backend/app/auth.py index 3e0e2e8..5ac5796 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,13 +1,13 @@ from __future__ import annotations -import base64 import hashlib import hmac -import os import secrets from datetime import date, datetime, timedelta, timezone import jwt +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from redis import Redis @@ -17,23 +17,30 @@ from .config import settings from .database import get_db -from .models import User, UserRole +from .models import RefreshSession, User, UserRole, utcnow bearer = HTTPBearer(auto_error=False) +password_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4) def hash_password(password: str) -> str: if len(password) < 10: raise ValueError("Password must contain at least 10 characters") - salt = os.urandom(16) - digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=64) - return f"scrypt$16384$8$1${base64.b64encode(salt).decode()}${base64.b64encode(digest).decode()}" + return password_hasher.hash(password) def verify_password(password: str, encoded: str | None) -> bool: if not encoded: return False + if encoded.startswith("$argon2id$"): + try: + return password_hasher.verify(encoded, password) + except (VerificationError, InvalidHashError): + return False + # Existing scrypt hashes remain valid and are upgraded after a successful login. try: + import base64 + _, n, r, p, salt, digest = encoded.split("$", 5) candidate = hashlib.scrypt( password.encode(), salt=base64.b64decode(salt), n=int(n), r=int(r), p=int(p), dklen=64 @@ -43,7 +50,7 @@ def verify_password(password: str, encoded: str | None) -> bool: return False -def token_pair(user: User) -> dict: +def access_token(user: User, session_id: str | None = None) -> dict: now = datetime.now(timezone.utc) expires = now + timedelta(minutes=settings.access_token_minutes) token = jwt.encode( @@ -51,15 +58,50 @@ def token_pair(user: User) -> dict: "sub": user.id, "role": user.role.value, "organization_id": user.organization_id, + "session_id": session_id, "iat": now, "exp": expires, "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, "jti": secrets.token_hex(12), }, settings.jwt_secret, algorithm="HS256", ) - return {"token": token, "expiresAt": expires.isoformat()} + return {"token": token, "access_token": token, "expiresAt": expires.isoformat(), "expires_in": settings.access_token_minutes * 60} + + +def create_refresh_session(db: Session, user: User, request: Request) -> tuple[str, RefreshSession]: + raw_token, token_hash = new_opaque_token() + session = RefreshSession( + user_id=user.id, + refresh_token_hash=token_hash, + user_agent=request.headers.get("user-agent"), + ip_address=request.client.host if request.client else None, + expires_at=utcnow() + timedelta(days=settings.refresh_token_days), + ) + db.add(session) + db.flush() + return raw_token, session + + +def rotate_refresh_session(db: Session, raw_token: str, request: Request) -> tuple[User, str, RefreshSession]: + session = db.scalar(select(RefreshSession).where(RefreshSession.refresh_token_hash == hash_token(raw_token)).with_for_update()) + now = utcnow() + if not session or session.revoked_at or session.expires_at <= now: + raise HTTPException(status_code=401, detail="Refresh session is invalid or expired") + user = db.get(User, session.user_id) + if not user or user.status != "active": + raise HTTPException(status_code=401, detail="Account is not active") + session.revoked_at = now + session.last_used_at = now + next_token, next_session = create_refresh_session(db, user, request) + return user, next_token, next_session + + +def token_pair(user: User, session_id: str | None = None) -> dict: + """Compatibility alias used by the original API.""" + return access_token(user, session_id) def hash_token(token: str) -> str: @@ -83,12 +125,18 @@ def current_user( settings.jwt_secret, algorithms=["HS256"], issuer=settings.jwt_issuer, + audience=settings.jwt_audience, ) except jwt.PyJWTError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session") from exc user = db.get(User, payload.get("sub")) if not user or user.status != "active": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is not active") + session_id = payload.get("session_id") + if session_id: + session = db.get(RefreshSession, session_id) + if not session or session.revoked_at or session.expires_at <= utcnow(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked") return user @@ -128,5 +176,27 @@ def check_login_rate_limit(request: Request, email: str) -> None: return +def check_public_rate_limit(request: Request, scope: str, identity: str, limit: int, window_seconds: int = 60) -> None: + """Bound anonymous token workflows without putting their durable state in Redis.""" + client = request.client.host if request.client else "unknown" + identity_hash = hashlib.sha256(identity.encode()).hexdigest()[:20] + key = f"fdx:rate:{scope}:{client}:{identity_hash}" + try: + redis = Redis.from_url(settings.redis_url, decode_responses=True, socket_connect_timeout=1) + attempts = redis.incr(key) + if attempts == 1: + redis.expire(key, window_seconds) + if attempts > limit: + raise HTTPException( + status_code=429, + detail="Too many requests. Try again later.", + headers={"Retry-After": str(window_seconds)}, + ) + except RedisError: + # Public links remain backed by high-entropy, expiring, hashed tokens. NGINX + # provides the coarse fallback when Redis is temporarily unavailable. + return + + def find_user_by_email(db: Session, email: str) -> User | None: return db.scalar(select(User).where(User.email == email.strip().lower())) diff --git a/backend/app/config.py b/backend/app/config.py index 88f3e7d..8c850e5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,7 +18,13 @@ class Settings: kafka_security_protocol: str = os.getenv("KAFKA_SECURITY_PROTOCOL", "PLAINTEXT") jwt_secret: str = os.getenv("JWT_SECRET", "change-this-development-secret") jwt_issuer: str = os.getenv("JWT_ISSUER", "fdx-api") - access_token_minutes: int = int(os.getenv("ACCESS_TOKEN_MINUTES", "480")) + jwt_audience: str = os.getenv("JWT_AUDIENCE", "fdx-web") + access_token_minutes: int = int(os.getenv("ACCESS_TOKEN_MINUTES", "15")) + refresh_token_days: int = int(os.getenv("REFRESH_TOKEN_DAYS", "7")) + invitation_token_hours: int = int(os.getenv("INVITATION_TOKEN_HOURS", "72")) + password_reset_minutes: int = int(os.getenv("PASSWORD_RESET_MINUTES", "30")) + enrollment_token_days: int = int(os.getenv("ENROLLMENT_TOKEN_DAYS", "7")) + gallery_token_days: int = int(os.getenv("GALLERY_TOKEN_DAYS", "7")) frontend_url: str = os.getenv("FRONTEND_URL", "http://127.0.0.1:8080") ml_service_url: str = os.getenv("ML_SERVICE_URL", "http://127.0.0.1:3000") storage_backend: str = os.getenv("STORAGE_BACKEND", "local") @@ -32,8 +38,22 @@ class Settings: super_admin_password: str = os.getenv("FDX_SUPER_ADMIN_PASSWORD", "SuperAdmin@123") environment: str = os.getenv("FDX_ENVIRONMENT", "development") retention_scheduler_enabled: bool = os.getenv("RETENTION_SCHEDULER_ENABLED", "true").lower() in {"1", "true", "yes"} + retention_poll_seconds: int = int(os.getenv("RETENTION_POLL_SECONDS", "60")) email_poll_seconds: int = int(os.getenv("EMAIL_POLL_SECONDS", "5")) email_max_attempts: int = int(os.getenv("EMAIL_MAX_ATTEMPTS", "5")) + consent_policy_version: str = os.getenv("CONSENT_POLICY_VERSION", "2026-08-13") + upload_reservation_minutes: int = int(os.getenv("UPLOAD_RESERVATION_MINUTES", "60")) + max_upload_bytes: int = int(os.getenv("MAX_UPLOAD_BYTES", str(100 * 1024**3))) + match_auto_threshold: float = float(os.getenv("MATCH_AUTO_THRESHOLD", "0.85")) + match_review_threshold: float = float(os.getenv("MATCH_REVIEW_THRESHOLD", "0.65")) + match_runner_up_margin: float = float(os.getenv("MATCH_RUNNER_UP_MARGIN", "0.08")) + threshold_profile_version: str = os.getenv("THRESHOLD_PROFILE_VERSION", "default-v1") + detector_model_version: str = os.getenv("FDX_DETECTOR_MODEL_VERSION", "retinaface-r50-v1") + embedder_model_version: str = os.getenv("FDX_EMBEDDER_MODEL_VERSION", "adaface-ir101-ms1mv2-v1") + detector_model_sha256: str = os.getenv("FDX_DETECTOR_MODEL_SHA256", "a607583ad9913b3a54f1b750752ae3f451fe324777df5542921e4b0b8e596a87") + embedder_model_sha256: str = os.getenv("FDX_EMBEDDER_MODEL_SHA256", "c594643ebe011c2534dd870d4abb0635ec27ce58e50b53f40b8d888a395e575e") + resend_webhook_secret: str = os.getenv("RESEND_WEBHOOK_SECRET", "") + email_webhook_secret: str = os.getenv("EMAIL_WEBHOOK_SECRET", "") settings = Settings() diff --git a/backend/app/integrations.py b/backend/app/integrations.py index 154aaba..6d48843 100644 --- a/backend/app/integrations.py +++ b/backend/app/integrations.py @@ -53,11 +53,49 @@ def delete(self, key: str) -> None: if self.root in target.parents: target.unlink(missing_ok=True) + def presign_put(self, key: str, content_type: str, expires: int = 900) -> str | None: + if not self.s3: + return None + return self.s3.generate_presigned_url( + "put_object", + Params={"Bucket": settings.s3_bucket, "Key": key, "ContentType": content_type}, + ExpiresIn=expires, + ) + + def presign_get(self, key: str, expires: int = 600, filename: str | None = None) -> str | None: + if not self.s3: + return None + params = {"Bucket": settings.s3_bucket, "Key": key} + if filename: + params["ResponseContentDisposition"] = f'attachment; filename="{filename}"' + return self.s3.generate_presigned_url("get_object", Params=params, ExpiresIn=expires) + + def stat(self, key: str) -> dict: + if self.s3: + result = self.s3.head_object(Bucket=settings.s3_bucket, Key=key) + return { + "size": result["ContentLength"], + "content_type": result.get("ContentType") or "application/octet-stream", + "etag": result.get("ETag", "").strip('"'), + } + target = (self.root / key).resolve() + if self.root not in target.parents or not target.is_file(): + raise FileNotFoundError(key) + return { + "size": target.stat().st_size, + "content_type": mimetypes.guess_type(target.name)[0] or "application/octet-stream", + "etag": None, + } + storage = Storage() def publish_job(payload: dict) -> bool: + return publish_event(settings.kafka_topic, payload) + + +def publish_event(topic: str, payload: dict) -> bool: try: producer = KafkaProducer( bootstrap_servers=settings.kafka_bootstrap_servers.split(","), @@ -66,7 +104,7 @@ def publish_job(payload: dict) -> bool: request_timeout_ms=2500, api_version_auto_timeout_ms=2500, ) - producer.send(settings.kafka_topic, payload).get(timeout=5) + producer.send(topic, payload).get(timeout=5) producer.close() return True except KafkaError as exc: diff --git a/backend/app/main.py b/backend/app/main.py index eba47c1..4a407e1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,9 +3,14 @@ import csv import hashlib import io +import json +import logging import mimetypes import secrets import tempfile +import threading +import time +import uuid import zipfile from contextlib import asynccontextmanager from datetime import date, datetime, timedelta, timezone @@ -13,8 +18,9 @@ import xlrd from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response, StreamingResponse +from fastapi.responses import JSONResponse, Response, StreamingResponse from openpyxl import load_workbook from openpyxl.utils.exceptions import InvalidFileException from PIL import Image, ImageOps @@ -50,24 +56,32 @@ ) from .models import ( AuditLog, + Consent, Delivery, EmailOutbox, Event, FaceDetection, FaceEnrollment, FaceMatch, + ModelRegistry, Organization, OrganizationType, Participant, Photo, ProcessingJob, User, + UserInvitation, UserRole, utcnow, ) from .serializers import event_json, iso, organization_json, participant_json, user_json GB = 1024**3 +METRICS_LOCK = threading.Lock() +REQUEST_METRICS = {"requests": 0, "latency_seconds": 0.0, "responses_4xx": 0, "responses_5xx": 0, "rate_limited": 0, "auth_failures": 0} +# Reuse Uvicorn's configured service logger so JSON request records are emitted +# consistently in containers without installing a second handler. +logger = logging.getLogger("uvicorn.error") class LoginInput(BaseModel): @@ -141,13 +155,30 @@ def bootstrap() -> None: raise RuntimeError("Production requires a unique JWT_SECRET of at least 32 characters") if settings.super_admin_password in {"SuperAdmin@123", "replace-with-a-strong-bootstrap-password"}: raise RuntimeError("Production requires a unique FDX_SUPER_ADMIN_PASSWORD") - Base.metadata.create_all(engine) + # Production schema ownership belongs exclusively to reviewed Alembic + # migrations. Local development keeps the convenience bootstrap. + if settings.environment != "production": + Base.metadata.create_all(engine) with SessionLocal() as db: existing = find_user_by_email(db, settings.super_admin_email) if not existing: db.add(User(name="FDX Super Admin", email=settings.super_admin_email.lower(), password_hash=hash_password(settings.super_admin_password), role=UserRole.SUPER_ADMIN, status="active")) db.add(AuditLog(actor="system", action="Platform initialized", details="Initial Super Admin account created", level="info")) db.commit() + if not db.scalar(select(ModelRegistry).where(ModelRegistry.active.is_(True))): + db.add(ModelRegistry( + detector_name="retinaface-r50", + detector_version=settings.detector_model_version, + detector_sha256=settings.detector_model_sha256, + embedder_name="adaface-ir101-ms1mv2", + embedder_version=settings.embedder_model_version, + embedder_sha256=settings.embedder_model_sha256, + embedding_dimension=512, + metric="cosine", + threshold_profile_version=settings.threshold_profile_version, + active=True, + )) + db.commit() @asynccontextmanager @@ -160,6 +191,91 @@ async def lifespan(_: FastAPI): app.add_middleware(CORSMiddleware, allow_origins=[settings.frontend_url, "http://127.0.0.1:5173", "http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) +@app.middleware("http") +async def request_context(request: Request, call_next): + request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + request.state.correlation_id = request.headers.get("X-Correlation-ID") or request.state.request_id + started = time.perf_counter() + response = await call_next(request) + elapsed = time.perf_counter() - started + with METRICS_LOCK: + REQUEST_METRICS["requests"] += 1 + REQUEST_METRICS["latency_seconds"] += elapsed + if 400 <= response.status_code < 500: + REQUEST_METRICS["responses_4xx"] += 1 + if response.status_code >= 500: + REQUEST_METRICS["responses_5xx"] += 1 + if response.status_code == 429: + REQUEST_METRICS["rate_limited"] += 1 + if response.status_code == 401 and request.url.path.startswith("/api"): + REQUEST_METRICS["auth_failures"] += 1 + route = request.scope.get("route") + logger.info(json.dumps({ + "timestamp": utcnow().isoformat(), + "level": "INFO", + "service": "fdx-api", + "request_id": request.state.request_id, + "correlation_id": request.state.correlation_id, + "method": request.method, + "path": getattr(route, "path", "/redacted"), + "status": response.status_code, + "duration_ms": round(elapsed * 1000, 2), + }, separators=(",", ":"))) + response.headers["X-Request-ID"] = request.state.request_id + response.headers["X-Correlation-ID"] = request.state.correlation_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(self)" + response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'" + response.headers["X-Response-Time-Ms"] = f"{elapsed * 1000:.2f}" + if settings.environment == "production": + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response + + +@app.exception_handler(HTTPException) +async def http_error(request: Request, exc: HTTPException): + if not request.url.path.startswith("/api/v2"): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers) + code = { + 400: "BAD_REQUEST", + 401: "AUTHENTICATION_REQUIRED", + 403: "FORBIDDEN", + 404: "RESOURCE_NOT_FOUND", + 409: "STATE_CONFLICT", + 413: "UPLOAD_TOO_LARGE", + 422: "VALIDATION_ERROR", + 429: "RATE_LIMITED", + 503: "DEPENDENCY_UNAVAILABLE", + }.get(exc.status_code, "REQUEST_FAILED") + return JSONResponse( + status_code=exc.status_code, + content={"error": {"code": code, "message": str(exc.detail), "details": {}}, "meta": {"request_id": request.state.request_id}}, + headers=exc.headers, + ) + + +@app.exception_handler(RequestValidationError) +async def validation_error(request: Request, exc: RequestValidationError): + if not request.url.path.startswith("/api/v2"): + return JSONResponse(status_code=422, content={"detail": exc.errors()}) + return JSONResponse( + status_code=422, + content={"error": {"code": "VALIDATION_ERROR", "message": "Request validation failed.", "details": {"errors": exc.errors()}}, "meta": {"request_id": request.state.request_id}}, + ) + + +@app.exception_handler(Exception) +async def unexpected_error(request: Request, exc: Exception): + logger.exception("Unhandled API error", exc_info=exc) + if not request.url.path.startswith("/api/v2"): + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + return JSONResponse( + status_code=500, + content={"error": {"code": "INTERNAL_ERROR", "message": "The request could not be completed.", "details": {}}, "meta": {"request_id": request.state.request_id}}, + ) + + @app.get("/health") @app.get("/api/health") def health(db: Session = Depends(get_db)): @@ -173,6 +289,41 @@ def health(db: Session = Depends(get_db)): return {"status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", "services": services} +@app.get("/health/live") +def health_live(): + return {"status": "alive"} + + +@app.get("/health/ready") +def health_ready(db: Session = Depends(get_db)): + db.execute(text("SELECT 1")) + return {"status": "ready"} + + +@app.get("/health/dependencies") +def health_dependencies(db: Session = Depends(get_db)): + db.execute(text("SELECT 1")) + services = [{"name": "PostgreSQL", "status": "healthy"}, *dependency_health()] + return {"status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", "services": services} + + +@app.get("/metrics", include_in_schema=False) +def metrics(): + """Small dependency-free Prometheus surface for API-level service metrics.""" + with METRICS_LOCK: + snapshot = dict(REQUEST_METRICS) + values = { + "fdx_api_requests_total": snapshot["requests"], + "fdx_api_request_duration_seconds_sum": snapshot["latency_seconds"], + "fdx_api_responses_4xx_total": snapshot["responses_4xx"], + "fdx_api_responses_5xx_total": snapshot["responses_5xx"], + "fdx_api_rate_limited_total": snapshot["rate_limited"], + "fdx_api_auth_failures_total": snapshot["auth_failures"], + } + body = "\n".join(f"# TYPE {name} counter\n{name} {value}" for name, value in values.items()) + "\n" + return Response(content=body, media_type="text/plain; version=0.0.4") + + @app.post("/api/auth/login") def login(payload: LoginInput, request: Request, db: Session = Depends(get_db)): email = str(payload.email).lower() @@ -268,6 +419,7 @@ def invite_user(payload: UserInviteInput, user: User = Depends(require_super_adm invited = User(organization_id=organization.id, name=payload.name.strip(), email=str(payload.email).lower(), role=UserRole.ORG_ADMIN, status="invited", invite_token_hash=token_hash, invite_expires_at=utcnow() + timedelta(days=7)) db.add(invited) db.flush() + db.add(UserInvitation(user_id=invited.id, token_hash=token_hash, expires_at=invited.invite_expires_at)) invite_url = f"{settings.frontend_url}/accept-invite/{raw_token}" email = queue_email(db, organization.id, invited.email, "You have been invited to FDX", f"

Hello {invited.name},

You have been invited to manage {organization.name} in FDX.

This link expires in 7 days.

") dispatch_email(db, email) @@ -293,6 +445,7 @@ def invite_staff(payload: StaffInviteInput, user: User = Depends(require_org_adm invited = User(organization_id=user.organization_id, name=payload.name.strip(), email=str(payload.email).lower(), role=UserRole.STAFF, status="invited", invite_token_hash=token_hash, invite_expires_at=utcnow() + timedelta(days=7)) db.add(invited) db.flush() + db.add(UserInvitation(user_id=invited.id, token_hash=token_hash, expires_at=invited.invite_expires_at)) invite_url = f"{settings.frontend_url}/accept-invite/{raw_token}" email = queue_email(db, user.organization_id, invited.email, f"You have been invited to {user.organization.name} on FDX", f"

Hello {invited.name},

You have been invited as event operations staff for {user.organization.name}.

Set your password

This link expires in 7 days.

") dispatch_email(db, email) @@ -752,7 +905,7 @@ def enrollment_info(token: str, db: Session = Depends(get_db)): @app.post("/api/public/enroll/{token}") -def enroll_face(token: str, consent: bool = Form(...), selfie: UploadFile = File(...), db: Session = Depends(get_db)): +def enroll_face(token: str, request: Request, consent: bool = Form(...), selfie: UploadFile = File(...), db: Session = Depends(get_db)): participant = db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) if not participant or participant.enrollment_expires_at < utcnow(): raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") @@ -771,14 +924,33 @@ def enroll_face(token: str, consent: bool = Form(...), selfie: UploadFile = File if participant.event.organization.storage_used_bytes + added_size > participant.event.organization.storage_limit_bytes: raise HTTPException(status_code=413, detail="Organization storage quota would be exceeded") storage.put(key, content, selfie.content_type or "image/jpeg") - enrollment = participant.enrollment or FaceEnrollment(participant_id=participant.id, storage_key=key, embedding=result["embedding"], detector_confidence=result["box"]["probability"]) + enrollment = participant.enrollment or FaceEnrollment( + participant_id=participant.id, + organization_id=participant.organization_id, + event_id=participant.event_id, + storage_key=key, + embedding=result["embedding"], + detector_confidence=result["box"]["probability"], + expires_at=datetime.combine(participant.event.expires_at, datetime.min.time(), timezone.utc), + ) enrollment.storage_key = key enrollment.size_bytes = len(content) enrollment.embedding = result["embedding"] + enrollment.embedding_vector = result["embedding"] enrollment.detector_confidence = result["box"]["probability"] db.add(enrollment) participant.enrollment_status = "verified" participant.consented_at = utcnow() + db.add(Consent( + organization_id=participant.organization_id, + event_id=participant.event_id, + participant_id=participant.id, + consent_type="face_enrollment", + policy_version=settings.consent_policy_version, + accepted=True, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + )) participant.event.organization.storage_used_bytes += added_size audit(db, None, "Face enrollment completed", f"{participant.event.name}: {participant.email}", organization_id=participant.organization_id) db.commit() @@ -841,3 +1013,8 @@ def chunks(): while content := archive_buffer.read(1024 * 1024): yield content return StreamingResponse(chunks(), media_type="application/zip", headers={"Cache-Control": "no-store", "Content-Disposition": f'attachment; filename="{base_name}-photos.zip"'}, background=BackgroundTask(archive_buffer.close)) + + +from .v2 import router as v2_router # noqa: E402 + +app.include_router(v2_router) diff --git a/backend/app/models.py b/backend/app/models.py index 2c77d33..1fa15e6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,9 +4,11 @@ import uuid from datetime import date, datetime, timezone +from pgvector.sqlalchemy import Vector from sqlalchemy import ( JSON, BigInteger, + Boolean, Date, DateTime, Enum, @@ -73,6 +75,7 @@ class User(Base): invite_token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True) invite_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) organization: Mapped[Organization | None] = relationship(back_populates="users") @@ -88,6 +91,12 @@ class Event(Base): retention_days: Mapped[int] = mapped_column(Integer) expires_at: Mapped[date] = mapped_column(Date, index=True) status: Mapped[str] = mapped_column(String(24), default="preparing", index=True) + starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + enrollment_opens_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + enrollment_closes_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + gallery_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_by: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) organization: Mapped[Organization] = relationship(back_populates="events") participants: Mapped[list["Participant"]] = relationship(back_populates="event") @@ -120,7 +129,17 @@ class FaceEnrollment(Base): storage_key: Mapped[str] = mapped_column(String(500)) size_bytes: Mapped[int] = mapped_column(BigInteger, default=0) embedding: Mapped[list] = mapped_column(JSON) + embedding_vector: Mapped[list | None] = mapped_column(Vector(512), nullable=True) detector_confidence: Mapped[float] = mapped_column(Float) + organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True) + event_id: Mapped[str | None] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(24), default="valid", index=True) + model_name: Mapped[str] = mapped_column(String(120), default="adaface-ir101-ms1mv2") + model_version: Mapped[str] = mapped_column(String(80), default="1") + embedding_dimension: Mapped[int] = mapped_column(Integer, default=512) + quality_score: Mapped[float | None] = mapped_column(Float, nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) participant: Mapped[Participant] = relationship(back_populates="enrollment") @@ -152,7 +171,15 @@ class FaceDetection(Base): photo_id: Mapped[str] = mapped_column(ForeignKey("photos.id", ondelete="CASCADE"), index=True) box: Mapped[dict] = mapped_column(JSON) embedding: Mapped[list] = mapped_column(JSON) + embedding_vector: Mapped[list | None] = mapped_column(Vector(512), nullable=True) detector_confidence: Mapped[float] = mapped_column(Float) + face_index: Mapped[int] = mapped_column(Integer, default=0) + landmarks: Mapped[dict | None] = mapped_column(JSON, nullable=True) + face_width: Mapped[int | None] = mapped_column(Integer, nullable=True) + face_height: Mapped[int | None] = mapped_column(Integer, nullable=True) + quality_class: Mapped[str] = mapped_column(String(24), default="GOOD") + model_name: Mapped[str] = mapped_column(String(120), default="retinaface-r50") + model_version: Mapped[str] = mapped_column(String(80), default="1") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) photo: Mapped[Photo] = relationship(back_populates="detections") match: Mapped["FaceMatch | None"] = relationship(back_populates="detection", uselist=False) @@ -166,7 +193,13 @@ class FaceMatch(Base): detection_id: Mapped[str] = mapped_column(ForeignKey("face_detections.id", ondelete="CASCADE"), unique=True) participant_id: Mapped[str | None] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), nullable=True, index=True) confidence: Mapped[float] = mapped_column(Float) + second_best_score: Mapped[float | None] = mapped_column(Float, nullable=True) + margin: Mapped[float | None] = mapped_column(Float, nullable=True) state: Mapped[str] = mapped_column(String(24), index=True) + decision_source: Mapped[str] = mapped_column(String(24), default="AUTO") + model_name: Mapped[str] = mapped_column(String(120), default="adaface-ir101-ms1mv2") + model_version: Mapped[str] = mapped_column(String(80), default="1") + threshold_profile_version: Mapped[str] = mapped_column(String(80), default="default-v1") reviewed_by: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) @@ -183,11 +216,18 @@ class ProcessingJob(Base): job_type: Mapped[str] = mapped_column(String(40)) status: Mapped[str] = mapped_column(String(24), default="queued", index=True) progress: Mapped[int] = mapped_column(Integer, default=0) + attempt: Mapped[int] = mapped_column(Integer, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, default=5) + progress_current: Mapped[int] = mapped_column(Integer, default=0) + progress_total: Mapped[int] = mapped_column(Integer, default=100) + correlation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) worker: Mapped[str | None] = mapped_column(String(80), nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class Delivery(Base): @@ -205,6 +245,22 @@ class Delivery(Base): event: Mapped[Event] = relationship() +class GalleryExport(Base): + __tablename__ = "gallery_exports" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) + event_id: Mapped[str] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + participant_id: Mapped[str] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True) + processing_job_id: Mapped[str] = mapped_column(ForeignKey("processing_jobs.id", ondelete="CASCADE"), unique=True) + status: Mapped[str] = mapped_column(String(24), default="QUEUED", index=True) + storage_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + size_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class EmailOutbox(Base): __tablename__ = "email_outbox" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) @@ -236,5 +292,178 @@ class AuditLog(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) +class UserInvitation(Base): + __tablename__ = "user_invitations" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class RefreshSession(Base): + __tablename__ = "refresh_sessions" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + refresh_token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class ParticipantEnrollmentToken(Base): + __tablename__ = "participant_enrollment_tokens" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + participant_id: Mapped[str] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class Consent(Base): + __tablename__ = "consents" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) + event_id: Mapped[str] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + participant_id: Mapped[str] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True) + consent_type: Mapped[str] = mapped_column(String(80), default="face_enrollment") + policy_version: Mapped[str] = mapped_column(String(80)) + accepted: Mapped[bool] = mapped_column(Boolean) + ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class ParticipantImport(Base): + __tablename__ = "participant_imports" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) + event_id: Mapped[str] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + source_object_key: Mapped[str | None] = mapped_column(Text, nullable=True) + source_filename: Mapped[str] = mapped_column(String(260)) + status: Mapped[str] = mapped_column(String(24), default="READY", index=True) + total_rows: Mapped[int] = mapped_column(Integer, default=0) + valid_rows: Mapped[int] = mapped_column(Integer, default=0) + invalid_rows: Mapped[int] = mapped_column(Integer, default=0) + duplicate_rows: Mapped[int] = mapped_column(Integer, default=0) + validation_report: Mapped[dict | None] = mapped_column(JSON, nullable=True) + normalized_rows: Mapped[list | None] = mapped_column(JSON, nullable=True) + created_by: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class UploadBatch(Base): + __tablename__ = "upload_batches" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) + event_id: Mapped[str] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + status: Mapped[str] = mapped_column(String(24), default="CREATED", index=True) + expected_files: Mapped[int | None] = mapped_column(Integer, nullable=True) + uploaded_files: Mapped[int] = mapped_column(Integer, default=0) + reserved_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + committed_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + manifest: Mapped[list | None] = mapped_column(JSON, nullable=True) + created_by: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class StorageReservation(Base): + __tablename__ = "storage_reservations" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) + event_id: Mapped[str] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + upload_batch_id: Mapped[str] = mapped_column(ForeignKey("upload_batches.id", ondelete="CASCADE"), unique=True) + bytes: Mapped[int] = mapped_column(BigInteger) + status: Mapped[str] = mapped_column(String(24), default="RESERVED", index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class StorageUsageLedger(Base): + __tablename__ = "storage_usage_ledger" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) + event_id: Mapped[str | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) + photo_id: Mapped[str | None] = mapped_column(ForeignKey("photos.id", ondelete="SET NULL"), nullable=True) + operation: Mapped[str] = mapped_column(String(24)) + bytes: Mapped[int] = mapped_column(BigInteger) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class OutboxEvent(Base): + __tablename__ = "outbox_events" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + aggregate_type: Mapped[str] = mapped_column(String(80)) + aggregate_id: Mapped[str] = mapped_column(String(36), index=True) + organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True) + event_type: Mapped[str] = mapped_column(String(160), index=True) + event_version: Mapped[int] = mapped_column(Integer, default=1) + payload: Mapped[dict] = mapped_column(JSON) + correlation_id: Mapped[str] = mapped_column(String(36), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + publish_attempts: Mapped[int] = mapped_column(Integer, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class IdempotencyRecord(Base): + __tablename__ = "idempotency_records" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + key: Mapped[str] = mapped_column(String(80)) + request_hash: Mapped[str] = mapped_column(String(64)) + response_status: Mapped[int | None] = mapped_column(Integer, nullable=True) + response_body: Mapped[dict | None] = mapped_column(JSON, nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + __table_args__ = (UniqueConstraint("user_id", "key", name="uq_idempotency_user_key"),) + + +class WebhookEvent(Base): + __tablename__ = "webhook_events" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + provider: Mapped[str] = mapped_column(String(30)) + provider_event_id: Mapped[str] = mapped_column(String(200)) + payload: Mapped[dict] = mapped_column(JSON) + received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + __table_args__ = (UniqueConstraint("provider", "provider_event_id", name="uq_webhook_provider_event"),) + + +class ModelRegistry(Base): + __tablename__ = "model_registry" + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) + detector_name: Mapped[str] = mapped_column(String(120)) + detector_version: Mapped[str] = mapped_column(String(80)) + detector_sha256: Mapped[str] = mapped_column(String(64)) + embedder_name: Mapped[str] = mapped_column(String(120)) + embedder_version: Mapped[str] = mapped_column(String(80)) + embedder_sha256: Mapped[str] = mapped_column(String(64)) + embedding_dimension: Mapped[int] = mapped_column(Integer, default=512) + metric: Mapped[str] = mapped_column(String(24), default="cosine") + threshold_profile_version: Mapped[str] = mapped_column(String(80)) + active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + Index("ix_matches_event_state", FaceMatch.event_id, FaceMatch.state) Index("ix_jobs_org_status", ProcessingJob.organization_id, ProcessingJob.status) diff --git a/backend/app/v2.py b/backend/app/v2.py new file mode 100644 index 0000000..3c70f1b --- /dev/null +++ b/backend/app/v2.py @@ -0,0 +1,1712 @@ +"""FDX V2 API contract. + +The V2 router is additive: the original `/api` routes remain available to the +current dashboard while clients migrate to the versioned, enveloped contract. +""" + +from __future__ import annotations + +import csv +import hashlib +import hmac +import io +import json +import uuid +from datetime import date, datetime, timedelta, timezone + +import xlrd +from fastapi import ( + APIRouter, + Cookie, + Depends, + File, + Form, + Header, + HTTPException, + Request, + Response, + UploadFile, +) +from openpyxl import load_workbook +from PIL import Image +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .auth import ( + access_token, + check_login_rate_limit, + check_public_rate_limit, + create_refresh_session, + current_user, + find_user_by_email, + hash_password, + hash_token, + new_opaque_token, + require_org_admin, + require_org_member, + require_super_admin, + rotate_refresh_session, + verify_password, +) +from .config import settings +from .database import get_db +from .integrations import ( + dependency_health, + dispatch_email, + ml_embedding, + queue_email, + storage, +) +from .models import ( + AuditLog, + Consent, + Delivery, + EmailOutbox, + Event, + FaceDetection, + FaceEnrollment, + FaceMatch, + GalleryExport, + IdempotencyRecord, + Organization, + OrganizationType, + OutboxEvent, + Participant, + ParticipantEnrollmentToken, + ParticipantImport, + PasswordResetToken, + Photo, + ProcessingJob, + RefreshSession, + StorageReservation, + StorageUsageLedger, + UploadBatch, + User, + UserInvitation, + UserRole, + WebhookEvent, + utcnow, +) +from .serializers import event_json, organization_json + +router = APIRouter(prefix="/api/v2") +GB = 1024**3 +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} +EVENT_TRANSITIONS = { + "DRAFT": {"ENROLLMENT_OPEN", "DELETION_PENDING"}, + "ENROLLMENT_OPEN": {"READY_FOR_UPLOAD", "DELETION_PENDING"}, + "READY_FOR_UPLOAD": {"UPLOADING", "PROCESSING", "DELETION_PENDING"}, + "UPLOADING": {"PROCESSING", "DELETION_PENDING"}, + "PROCESSING": {"REVIEW", "READY_TO_DELIVER", "DELETION_PENDING"}, + "REVIEW": {"READY_TO_DELIVER", "PROCESSING", "DELETION_PENDING"}, + "READY_TO_DELIVER": {"DELIVERING", "PROCESSING", "DELETION_PENDING"}, + "DELIVERING": {"DELIVERED", "READY_TO_DELIVER", "DELETION_PENDING"}, + "DELIVERED": {"ARCHIVED", "DELETION_PENDING"}, + "ARCHIVED": {"EXPIRED", "DELETION_PENDING"}, + "EXPIRED": {"DELETION_PENDING"}, + "DELETION_PENDING": {"DELETED"}, +} + + +def ok(data=None, request: Request | None = None, **meta): + return {"data": data, "meta": {"request_id": getattr(request.state, "request_id", None) if request else None, **meta}} + + +def add_audit( + db: Session, + user: User | None, + action: str, + details: str, + organization_id: str | None = None, + level: str = "info", +) -> None: + db.add( + AuditLog( + organization_id=organization_id if organization_id is not None else user.organization_id if user else None, + actor_user_id=user.id if user else None, + actor=user.email if user else "system", + action=action, + details=details, + level=level, + ) + ) + + +def add_outbox( + db: Session, + event_type: str, + aggregate_type: str, + aggregate_id: str, + payload: dict, + organization_id: str | None, + correlation_id: str, +) -> OutboxEvent: + event = OutboxEvent( + aggregate_type=aggregate_type, + aggregate_id=aggregate_id, + organization_id=organization_id, + event_type=event_type, + event_version=1, + correlation_id=correlation_id, + payload=payload, + ) + db.add(event) + return event + + +def request_id(request: Request) -> str: + return request.state.request_id + + +def user_v2(user: User) -> dict: + return { + "id": user.id, + "name": user.name, + "email": user.email, + "role": user.role.value, + "organization_id": user.organization_id, + "status": user.status.upper(), + } + + +def set_refresh_cookie(response: Response, token: str) -> None: + response.set_cookie( + "fdx_refresh", + token, + max_age=settings.refresh_token_days * 86400, + httponly=True, + secure=settings.environment == "production", + samesite="strict", + path="/api/v2/auth", + ) + + +def clear_refresh_cookie(response: Response) -> None: + response.delete_cookie("fdx_refresh", path="/api/v2/auth", secure=settings.environment == "production", samesite="strict") + + +def tenant_event(db: Session, user: User, event_id: str, lock: bool = False) -> Event: + statement = select(Event).where(Event.id == event_id, Event.organization_id == user.organization_id) + if lock: + statement = statement.with_for_update() + event = db.scalar(statement) + if not event: + raise HTTPException(status_code=404, detail="Event was not found") + return event + + +def pagination(page: int, page_size: int) -> tuple[int, int]: + if page < 1 or page_size < 1 or page_size > 100: + raise HTTPException(status_code=422, detail="page must be >= 1 and page_size must be between 1 and 100") + return (page - 1) * page_size, page_size + + +def reserve_idempotency(db: Session, user: User, key: str | None, body: str) -> IdempotencyRecord | None: + if not key: + return None + digest = hashlib.sha256(body.encode()).hexdigest() + record = db.scalar(select(IdempotencyRecord).where(IdempotencyRecord.user_id == user.id, IdempotencyRecord.key == key)) + if record: + if record.request_hash != digest: + raise HTTPException(status_code=409, detail="Idempotency key was already used for a different request") + return record + record = IdempotencyRecord(user_id=user.id, key=key, request_hash=digest, expires_at=utcnow() + timedelta(days=1)) + db.add(record) + db.flush() + return record + + +class LoginInput(BaseModel): + email: EmailStr + password: str + + +class PasswordInput(BaseModel): + password: str = Field(min_length=10) + + +class ForgotPasswordInput(BaseModel): + email: EmailStr + + +class ResetPasswordInput(BaseModel): + token: str + password: str = Field(min_length=10) + + +class OrganizationInput(BaseModel): + name: str = Field(min_length=2, max_length=180) + organization_type: OrganizationType + primary_email: EmailStr + contact_name: str = "" + phone: str = "" + storage_limit_bytes: int = Field(default=100 * GB, gt=0) + default_retention_days: int = Field(default=90, ge=1, le=3650) + account_expires_at: date | None = None + + +class OrganizationUpdate(BaseModel): + name: str | None = Field(default=None, min_length=2, max_length=180) + primary_email: EmailStr | None = None + contact_name: str | None = None + phone: str | None = None + storage_limit_bytes: int | None = Field(default=None, gt=0) + default_retention_days: int | None = Field(default=None, ge=1, le=3650) + account_expires_at: date | None = None + + +class InviteUserInput(BaseModel): + name: str = Field(min_length=2, max_length=120) + email: EmailStr + + +class EventInput(BaseModel): + name: str = Field(min_length=2, max_length=180) + description: str = "" + location: str = "" + starts_at: datetime + ends_at: datetime | None = None + retention_days: int | None = Field(default=None, ge=1, le=3650) + enrollment_opens_at: datetime | None = None + enrollment_closes_at: datetime | None = None + gallery_expires_at: datetime | None = None + + +class EventUpdate(BaseModel): + name: str | None = Field(default=None, min_length=2, max_length=180) + description: str | None = None + location: str | None = None + starts_at: datetime | None = None + ends_at: datetime | None = None + retention_days: int | None = Field(default=None, ge=1, le=3650) + enrollment_opens_at: datetime | None = None + enrollment_closes_at: datetime | None = None + gallery_expires_at: datetime | None = None + + +class ParticipantInput(BaseModel): + name: str = Field(min_length=1, max_length=120) + email: EmailStr + + +class UploadBatchInput(BaseModel): + expected_files: int = Field(ge=1, le=100_000) + reserved_bytes: int = Field(gt=0) + + +class UploadObjectInput(BaseModel): + filename: str = Field(min_length=1, max_length=260) + content_type: str + size_bytes: int = Field(gt=0) + sha256: str = Field(pattern=r"^[a-fA-F0-9]{64}$") + + +class PresignInput(BaseModel): + files: list[UploadObjectInput] = Field(min_length=1, max_length=1000) + + +class MatchReviewInput(BaseModel): + decision: str + + +@router.post("/auth/login") +def login(payload: LoginInput, response: Response, request: Request, db: Session = Depends(get_db)): + email = str(payload.email).lower() + check_login_rate_limit(request, email) + user = find_user_by_email(db, email) + if not user or not verify_password(payload.password, user.password_hash): + add_audit(db, None, "auth.login.failed", "Invalid credentials", level="warning") + db.commit() + raise HTTPException(status_code=401, detail="Invalid email or password") + if user.status != "active" or (user.organization and user.organization.status != "active"): + raise HTTPException(status_code=403, detail="Account is not active") + if user.password_hash and not user.password_hash.startswith("$argon2id$"): + user.password_hash = hash_password(payload.password) + raw_refresh, session = create_refresh_session(db, user, request) + user.last_active_at = utcnow() + add_audit(db, user, "auth.login.succeeded", f"Session {session.id} issued") + tokens = access_token(user, session.id) + db.commit() + set_refresh_cookie(response, raw_refresh) + return ok( + { + "access_token": tokens["access_token"], + "expires_in": tokens["expires_in"], + "user": user_v2(user), + "redirect_to": "/admin" if user.role == UserRole.SUPER_ADMIN else "/organization", + }, + request, + ) + + +@router.post("/auth/refresh") +def refresh(response: Response, request: Request, fdx_refresh: str | None = Cookie(default=None), db: Session = Depends(get_db)): + if not fdx_refresh: + raise HTTPException(status_code=401, detail="Refresh session is required") + user, raw_refresh, session = rotate_refresh_session(db, fdx_refresh, request) + tokens = access_token(user, session.id) + db.commit() + set_refresh_cookie(response, raw_refresh) + return ok({"access_token": tokens["access_token"], "expires_in": tokens["expires_in"], "user": user_v2(user)}, request) + + +@router.post("/auth/logout", status_code=204) +def logout(response: Response, fdx_refresh: str | None = Cookie(default=None), db: Session = Depends(get_db)): + if fdx_refresh: + session = db.scalar(select(RefreshSession).where(RefreshSession.refresh_token_hash == hash_token(fdx_refresh))) + if session and not session.revoked_at: + session.revoked_at = utcnow() + user = db.get(User, session.user_id) + add_audit(db, user, "auth.logout", f"Session {session.id} revoked") + db.commit() + clear_refresh_cookie(response) + + +@router.get("/auth/me") +def me(request: Request, user: User = Depends(current_user)): + return ok(user_v2(user), request) + + +@router.post("/auth/forgot-password", status_code=202) +def forgot_password(payload: ForgotPasswordInput, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "forgot-password", str(payload.email).lower(), 5, 300) + user = find_user_by_email(db, str(payload.email).lower()) + if user and user.status == "active": + db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id, PasswordResetToken.consumed_at.is_(None)).delete() + raw_token, token_hash = new_opaque_token() + db.add(PasswordResetToken(user_id=user.id, token_hash=token_hash, expires_at=utcnow() + timedelta(minutes=settings.password_reset_minutes))) + url = f"{settings.frontend_url}/reset-password/{raw_token}" + item = queue_email(db, user.organization_id, user.email, "Reset your FDX password", f"

Reset password

") + dispatch_email(db, item) + add_audit(db, user, "auth.password_reset.requested", "Password reset requested") + db.commit() + return ok({"message": "If the account exists, a password reset email has been queued."}, request) + + +@router.post("/auth/reset-password") +def reset_password(payload: ResetPasswordInput, request: Request, db: Session = Depends(get_db)): + token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_token(payload.token)).with_for_update()) + if not token or token.consumed_at or token.expires_at <= utcnow(): + raise HTTPException(status_code=404, detail="Password reset token is invalid or expired") + user = db.get(User, token.user_id) + user.password_hash = hash_password(payload.password) + token.consumed_at = utcnow() + db.query(RefreshSession).filter(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}) + add_audit(db, user, "auth.password_reset.completed", "Password changed and sessions revoked") + db.commit() + return ok({"message": "Password reset completed."}, request) + + +@router.post("/auth/invitations/{token}/accept") +def accept_invitation(token: str, payload: PasswordInput, response: Response, request: Request, db: Session = Depends(get_db)): + invitation = db.scalar(select(UserInvitation).where(UserInvitation.token_hash == hash_token(token)).with_for_update()) + if not invitation or invitation.accepted_at or invitation.revoked_at or invitation.expires_at <= utcnow(): + raise HTTPException(status_code=404, detail="Invitation is invalid or expired") + user = db.get(User, invitation.user_id) + user.password_hash = hash_password(payload.password) + user.status = "active" + invitation.accepted_at = utcnow() + raw_refresh, session = create_refresh_session(db, user, request) + add_audit(db, user, "user.invitation.accepted", f"Invitation {invitation.id} accepted") + tokens = access_token(user, session.id) + db.commit() + set_refresh_cookie(response, raw_refresh) + return ok({"access_token": tokens["access_token"], "expires_in": tokens["expires_in"], "user": user_v2(user)}, request) + + +@router.get("/admin/dashboard") +def admin_dashboard(request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + def count(model, *filters): + return db.scalar(select(func.count(model.id)).where(*filters)) or 0 + + data = { + "organizations_total": count(Organization), + "organizations_active": count(Organization, Organization.status == "active"), + "organizations_suspended": count(Organization, Organization.status == "suspended"), + "organization_users": count(User, User.organization_id.is_not(None)), + "events_total": count(Event), + "photos_total": count(Photo), + "storage_used_bytes": db.scalar(select(func.coalesce(func.sum(Organization.storage_used_bytes), 0))) or 0, + "jobs_queued": count(ProcessingJob, ProcessingJob.status.in_(["queued", "QUEUED", "RETRY_SCHEDULED"])), + "jobs_running": count(ProcessingJob, ProcessingJob.status.in_(["processing", "RUNNING"])), + "jobs_failed": count(ProcessingJob, ProcessingJob.status.in_(["failed", "FAILED", "DEAD_LETTERED"])), + "emails_sent": count(EmailOutbox, EmailOutbox.status == "sent"), + "emails_failed": count(EmailOutbox, EmailOutbox.status == "failed"), + "expiring_events": count(Event, Event.expires_at <= date.today() + timedelta(days=7), Event.status.notin_(["expired", "DELETED"])), + } + return ok(data, request) + + +@router.get("/admin/system-health") +def system_health(request: Request, _: User = Depends(require_super_admin)): + services = dependency_health() + return ok({"status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", "services": services}, request) + + +@router.get("/admin/organizations") +def organizations(request: Request, page: int = 1, page_size: int = 50, search: str | None = None, status: str | None = None, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + offset, limit = pagination(page, page_size) + filters = [] + if search: + filters.append(Organization.name.ilike(f"%{search.strip()}%")) + if status: + filters.append(Organization.status == status.lower()) + total = db.scalar(select(func.count(Organization.id)).where(*filters)) or 0 + rows = db.scalars(select(Organization).where(*filters).order_by(Organization.created_at.desc()).offset(offset).limit(limit)).all() + return ok([organization_json(db, row) for row in rows], request, page=page, page_size=page_size, total=total) + + +@router.post("/admin/organizations", status_code=201) +def create_organization(payload: OrganizationInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = Organization( + name=payload.name.strip(), + type=payload.organization_type, + contact_name=payload.contact_name.strip(), + contact_email=str(payload.primary_email).lower(), + phone=payload.phone.strip(), + storage_limit_bytes=payload.storage_limit_bytes, + retention_days=payload.default_retention_days, + expires_at=payload.account_expires_at, + status="active", + ) + db.add(item) + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="Organization conflicts with an existing record") from exc + add_audit(db, user, "organization.created", item.name, item.id) + db.commit() + return ok(organization_json(db, item), request) + + +@router.get("/admin/organizations/{organization_id}") +def organization_detail(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + data = organization_json(db, item) + data["recent_audit"] = [ + {"id": row.id, "action": row.action, "details": row.details, "created_at": row.created_at.isoformat()} + for row in db.scalars(select(AuditLog).where(AuditLog.organization_id == item.id).order_by(AuditLog.created_at.desc()).limit(20)).all() + ] + return ok(data, request) + + +@router.patch("/admin/organizations/{organization_id}") +def update_organization(organization_id: str, payload: OrganizationUpdate, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + mapping = {"primary_email": "contact_email", "default_retention_days": "retention_days", "account_expires_at": "expires_at"} + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(item, mapping.get(key, key), str(value).lower() if key == "primary_email" else value) + add_audit(db, user, "organization.updated", ", ".join(payload.model_fields_set), item.id) + db.commit() + return ok(organization_json(db, item), request) + + +def set_org_status(organization_id: str, target: str, request: Request, user: User, db: Session): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + item.status = target + if target != "active": + session_ids = select(User.id).where(User.organization_id == item.id) + db.query(RefreshSession).filter(RefreshSession.user_id.in_(session_ids), RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}, synchronize_session=False) + add_audit(db, user, f"organization.{target}", item.name, item.id) + db.commit() + return ok(organization_json(db, item), request) + + +@router.post("/admin/organizations/{organization_id}/suspend") +def suspend_organization(organization_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + return set_org_status(organization_id, "suspended", request, user, db) + + +@router.post("/admin/organizations/{organization_id}/activate") +def activate_organization(organization_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + return set_org_status(organization_id, "active", request, user, db) + + +@router.post("/admin/organizations/{organization_id}/schedule-deletion", status_code=202) +def schedule_organization_deletion(organization_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + item.status = "deletion_pending" + correlation = request_id(request) + add_outbox(db, "fdx.v2.retention.cleanup.requested", "organization", item.id, {"resource_type": "organization", "resource_id": item.id}, item.id, correlation) + add_audit(db, user, "organization.deletion_scheduled", item.name, item.id) + db.commit() + return ok({"status": "DELETION_PENDING"}, request) + + +@router.get("/admin/organizations/{organization_id}/storage") +def organization_storage(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + reserved = db.scalar(select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where(StorageReservation.organization_id == item.id, StorageReservation.status == "RESERVED", StorageReservation.expires_at > utcnow())) or 0 + return ok({"storage_limit_bytes": item.storage_limit_bytes, "storage_used_bytes": item.storage_used_bytes, "storage_reserved_bytes": reserved, "storage_available_bytes": max(0, item.storage_limit_bytes - item.storage_used_bytes - reserved)}, request) + + +class StoragePolicyInput(BaseModel): + storage_limit_bytes: int = Field(gt=0) + + +@router.put("/admin/organizations/{organization_id}/storage") +def update_organization_storage(organization_id: str, payload: StoragePolicyInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + if payload.storage_limit_bytes < item.storage_used_bytes: + raise HTTPException(status_code=422, detail="Storage limit cannot be below current usage") + item.storage_limit_bytes = payload.storage_limit_bytes + add_audit(db, user, "organization.storage_policy.updated", str(payload.storage_limit_bytes), item.id) + db.commit() + return ok({"storage_limit_bytes": item.storage_limit_bytes, "storage_used_bytes": item.storage_used_bytes}, request) + + +@router.get("/admin/organizations/{organization_id}/retention") +def organization_retention(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + return ok({"default_retention_days": item.retention_days, "account_expires_at": item.expires_at.isoformat() if item.expires_at else None}, request) + + +class RetentionPolicyInput(BaseModel): + default_retention_days: int = Field(ge=1, le=3650) + account_expires_at: date | None = None + + +@router.put("/admin/organizations/{organization_id}/retention") +def update_organization_retention(organization_id: str, payload: RetentionPolicyInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = db.get(Organization, organization_id) + if not item: + raise HTTPException(status_code=404, detail="Organization was not found") + item.retention_days = payload.default_retention_days + item.expires_at = payload.account_expires_at + add_audit(db, user, "organization.retention_policy.updated", f"{payload.default_retention_days} days", item.id) + db.commit() + return ok({"default_retention_days": item.retention_days, "account_expires_at": item.expires_at.isoformat() if item.expires_at else None}, request) + + +@router.get("/admin/organizations/{organization_id}/users") +def organization_users(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + if not db.get(Organization, organization_id): + raise HTTPException(status_code=404, detail="Organization was not found") + rows = db.scalars(select(User).where(User.organization_id == organization_id).order_by(User.created_at.desc())).all() + return ok([user_v2(row) for row in rows], request) + + +@router.post("/admin/organizations/{organization_id}/users", status_code=201) +def invite_organization_user(organization_id: str, payload: InviteUserInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + organization = db.get(Organization, organization_id) + if not organization: + raise HTTPException(status_code=404, detail="Organization was not found") + email = str(payload.email).lower() + if find_user_by_email(db, email): + raise HTTPException(status_code=409, detail="Email is already registered") + invited = User(organization_id=organization.id, name=payload.name.strip(), email=email, role=UserRole.ORG_ADMIN, status="invited") + db.add(invited) + db.flush() + raw_token, token_hash = new_opaque_token() + invitation = UserInvitation(user_id=invited.id, token_hash=token_hash, expires_at=utcnow() + timedelta(hours=settings.invitation_token_hours)) + db.add(invitation) + url = f"{settings.frontend_url}/accept-invite/{raw_token}" + mail = queue_email(db, organization.id, invited.email, f"Join {organization.name} on FDX", f"

Set your password

") + dispatch_email(db, mail) + add_audit(db, user, "user.invited", invited.email, organization.id) + db.commit() + data = user_v2(invited) + if settings.environment == "development": + data["development_invitation_url"] = url + return ok(data, request) + + +def admin_user_or_404(db: Session, user_id: str) -> User: + item = db.get(User, user_id) + if not item or item.role == UserRole.SUPER_ADMIN: + raise HTTPException(status_code=404, detail="Organization user was not found") + return item + + +@router.get("/admin/users/{user_id}") +def admin_user_detail(user_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + return ok(user_v2(admin_user_or_404(db, user_id)), request) + + +class UserUpdate(BaseModel): + name: str | None = Field(default=None, min_length=2, max_length=120) + email: EmailStr | None = None + + +@router.patch("/admin/users/{user_id}") +def admin_update_user(user_id: str, payload: UserUpdate, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = admin_user_or_404(db, user_id) + if payload.name is not None: + item.name = payload.name.strip() + if payload.email is not None: + item.email = str(payload.email).lower() + add_audit(db, user, "user.updated", item.email, item.organization_id) + db.commit() + return ok(user_v2(item), request) + + +def set_user_status(user_id: str, target: str, request: Request, actor: User, db: Session): + item = admin_user_or_404(db, user_id) + item.status = target + if target != "active": + db.query(RefreshSession).filter(RefreshSession.user_id == item.id, RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}) + add_audit(db, actor, f"user.{target}", item.email, item.organization_id) + db.commit() + return ok(user_v2(item), request) + + +@router.post("/admin/users/{user_id}/suspend") +def admin_suspend_user(user_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + return set_user_status(user_id, "suspended", request, user, db) + + +@router.post("/admin/users/{user_id}/activate") +def admin_activate_user(user_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + return set_user_status(user_id, "active", request, user, db) + + +@router.post("/admin/users/{user_id}/resend-invite") +def admin_resend_invite(user_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + item = admin_user_or_404(db, user_id) + if item.password_hash: + raise HTTPException(status_code=409, detail="User has already activated the account") + db.query(UserInvitation).filter(UserInvitation.user_id == item.id, UserInvitation.accepted_at.is_(None), UserInvitation.revoked_at.is_(None)).update({"revoked_at": utcnow()}) + raw_token, token_hash = new_opaque_token() + invitation = UserInvitation(user_id=item.id, token_hash=token_hash, expires_at=utcnow() + timedelta(hours=settings.invitation_token_hours)) + db.add(invitation) + url = f"{settings.frontend_url}/accept-invite/{raw_token}" + mail = queue_email(db, item.organization_id, item.email, "Your FDX invitation", f"

Set your password

") + dispatch_email(db, mail) + add_audit(db, user, "user.invitation.resent", item.email, item.organization_id) + db.commit() + data = {"status": "QUEUED"} + if settings.environment == "development": + data["development_invitation_url"] = url + return ok(data, request) + + +@router.get("/admin/jobs") +def admin_jobs(request: Request, page: int = 1, page_size: int = 50, status: str | None = None, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + offset, limit = pagination(page, page_size) + filters = [ProcessingJob.status == status] if status else [] + total = db.scalar(select(func.count(ProcessingJob.id)).where(*filters)) or 0 + rows = db.scalars(select(ProcessingJob).where(*filters).order_by(ProcessingJob.created_at.desc()).offset(offset).limit(limit)).all() + data = [{"id": row.id, "organization_id": row.organization_id, "event_id": row.event_id, "job_type": row.job_type, "status": row.status, "attempt": row.attempt, "max_attempts": row.max_attempts, "progress_current": row.progress_current, "progress_total": row.progress_total, "error": row.error} for row in rows] + return ok(data, request, page=page, page_size=page_size, total=total) + + +@router.post("/admin/jobs/{job_id}/retry", status_code=202) +def admin_retry_job(job_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): + job = db.get(ProcessingJob, job_id) + if not job or job.status not in {"failed", "FAILED", "DEAD_LETTERED"}: + raise HTTPException(status_code=409, detail="Job is not retryable") + job.status = "queued" + job.error = None + job.next_attempt_at = utcnow() + add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": job.photo_id}, job.organization_id, request_id(request)) + add_audit(db, user, "processing.retry", job.id, job.organization_id) + db.commit() + return ok({"job_id": job.id, "status": "QUEUED"}, request) + + +@router.get("/admin/logs") +def admin_logs(request: Request, page: int = 1, page_size: int = 50, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): + offset, limit = pagination(page, page_size) + total = db.scalar(select(func.count(AuditLog.id))) or 0 + rows = db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)).all() + return ok([{"id": row.id, "organization_id": row.organization_id, "actor": row.actor, "action": row.action, "details": row.details, "level": row.level, "created_at": row.created_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + + +@router.get("/organization") +def current_organization(request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + return ok(organization_json(db, user.organization), request) + + +@router.get("/organization/dashboard") +def organization_dashboard(request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + events = db.scalars(select(Event).where(Event.organization_id == user.organization_id).order_by(Event.created_at.desc())).all() + data = { + "organization": organization_json(db, user.organization), + "events": [event_json(db, event) for event in events], + "participants": db.scalar(select(func.count(Participant.id)).where(Participant.organization_id == user.organization_id)) or 0, + "photos": db.scalar(select(func.count(Photo.id)).where(Photo.organization_id == user.organization_id)) or 0, + "failed_jobs": db.scalar(select(func.count(ProcessingJob.id)).where(ProcessingJob.organization_id == user.organization_id, ProcessingJob.status.in_(["failed", "FAILED", "DEAD_LETTERED"]))) or 0, + } + return ok(data, request) + + +@router.get("/organization/usage") +def organization_usage(request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + reserved = db.scalar(select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where(StorageReservation.organization_id == user.organization_id, StorageReservation.status == "RESERVED", StorageReservation.expires_at > utcnow())) or 0 + return ok({"used_bytes": user.organization.storage_used_bytes, "reserved_bytes": reserved, "limit_bytes": user.organization.storage_limit_bytes, "available_bytes": max(0, user.organization.storage_limit_bytes - user.organization.storage_used_bytes - reserved)}, request) + + +@router.get("/organization/logs") +def organization_logs(request: Request, page: int = 1, page_size: int = 50, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + offset, limit = pagination(page, page_size) + total = db.scalar(select(func.count(AuditLog.id)).where(AuditLog.organization_id == user.organization_id)) or 0 + rows = db.scalars(select(AuditLog).where(AuditLog.organization_id == user.organization_id).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)).all() + return ok([{"id": row.id, "actor": row.actor, "action": row.action, "details": row.details, "level": row.level, "created_at": row.created_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + + +@router.get("/events") +def list_events(request: Request, page: int = 1, page_size: int = 50, status: str | None = None, search: str | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + offset, limit = pagination(page, page_size) + filters = [Event.organization_id == user.organization_id] + if status: + filters.append(Event.status == status) + if search: + filters.append(Event.name.ilike(f"%{search.strip()}%")) + total = db.scalar(select(func.count(Event.id)).where(*filters)) or 0 + rows = db.scalars(select(Event).where(*filters).order_by(Event.created_at.desc()).offset(offset).limit(limit)).all() + return ok([event_json(db, row) for row in rows], request, page=page, page_size=page_size, total=total) + + +@router.post("/events", status_code=201) +def create_event(payload: EventInput, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + retention = min(payload.retention_days or user.organization.retention_days, user.organization.retention_days) + expires_at = (payload.starts_at + timedelta(days=retention)).date() + item = Event( + organization_id=user.organization_id, + name=payload.name.strip(), + description=payload.description, + location=payload.location, + event_date=payload.starts_at.date(), + starts_at=payload.starts_at, + ends_at=payload.ends_at, + retention_days=retention, + expires_at=expires_at, + enrollment_opens_at=payload.enrollment_opens_at, + enrollment_closes_at=payload.enrollment_closes_at, + gallery_expires_at=payload.gallery_expires_at, + created_by=user.id, + status="DRAFT", + ) + db.add(item) + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="An event with the same name and date already exists") from exc + add_audit(db, user, "event.created", item.name) + db.commit() + return ok(event_json(db, item), request) + + +@router.get("/events/{event_id}") +def get_event(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + return ok(event_json(db, tenant_event(db, user, event_id)), request) + + +@router.patch("/events/{event_id}") +def update_event(event_id: str, payload: EventUpdate, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + item = tenant_event(db, user, event_id, lock=True) + values = payload.model_dump(exclude_unset=True) + if "retention_days" in values: + values["retention_days"] = min(values["retention_days"], user.organization.retention_days) + for key, value in values.items(): + setattr(item, key, value) + if payload.starts_at: + item.event_date = payload.starts_at.date() + item.expires_at = (item.starts_at or datetime.combine(item.event_date, datetime.min.time(), timezone.utc)).date() + timedelta(days=item.retention_days) + add_audit(db, user, "event.updated", f"{item.name}: {', '.join(values)}") + db.commit() + return ok(event_json(db, item), request) + + +def transition_event(event_id: str, target: str, request: Request, user: User, db: Session): + item = tenant_event(db, user, event_id, lock=True) + current = item.status.upper() + if target not in EVENT_TRANSITIONS.get(current, set()): + raise HTTPException(status_code=409, detail=f"Event cannot transition from {current} to {target}") + item.status = target + add_audit(db, user, "event.state_changed", f"{current} -> {target}") + db.commit() + return ok({"id": item.id, "status": target}, request) + + +@router.post("/events/{event_id}/open-enrollment") +def open_enrollment(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + return transition_event(event_id, "ENROLLMENT_OPEN", request, user, db) + + +@router.post("/events/{event_id}/close-enrollment") +def close_enrollment(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + return transition_event(event_id, "READY_FOR_UPLOAD", request, user, db) + + +@router.post("/events/{event_id}/archive") +def archive_event(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + return transition_event(event_id, "ARCHIVED", request, user, db) + + +@router.delete("/events/{event_id}", status_code=202) +def delete_event(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + item = tenant_event(db, user, event_id, lock=True) + item.status = "DELETION_PENDING" + add_outbox(db, "fdx.v2.retention.cleanup.requested", "event", item.id, {"resource_type": "event", "resource_id": item.id}, user.organization_id, request_id(request)) + add_audit(db, user, "event.deletion_scheduled", item.name) + db.commit() + return ok({"id": item.id, "status": "DELETION_PENDING"}, request) + + +def parse_participants(content: bytes, filename: str) -> tuple[list[dict], list[dict], int]: + suffix = filename.lower().rsplit(".", 1)[-1] if "." in filename else "" + rows: list[dict] = [] + if suffix == "csv": + text = content.decode("utf-8-sig") + rows = [dict(row) for row in csv.DictReader(io.StringIO(text))] + elif suffix in {"xlsx", "xlsm"}: + workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + values = list(workbook.active.iter_rows(values_only=True)) + if values: + headers = [str(value or "").strip() for value in values[0]] + rows = [dict(zip(headers, values_row)) for values_row in values[1:]] + elif suffix == "xls": + workbook = xlrd.open_workbook(file_contents=content) + sheet = workbook.sheet_by_index(0) + headers = [str(sheet.cell_value(0, column)).strip() for column in range(sheet.ncols)] if sheet.nrows else [] + rows = [dict(zip(headers, sheet.row_values(index))) for index in range(1, sheet.nrows)] + else: + raise HTTPException(status_code=422, detail="Participant file must be CSV, XLS, XLSX, or XLSM") + valid, errors, seen = [], [], set() + for index, raw in enumerate(rows, start=2): + normalized = {str(key).strip().lower(): value for key, value in raw.items()} + name, email = str(normalized.get("name") or "").strip(), str(normalized.get("email") or "").strip().lower() + row_errors = [] + if not name: + row_errors.append("name is required") + if "@" not in email or email.startswith("@") or email.endswith("@"): + row_errors.append("email is invalid") + if email in seen: + row_errors.append("duplicate email in file") + if row_errors: + errors.append({"row": index, "name": name, "email": email, "errors": row_errors}) + else: + seen.add(email) + valid.append({"name": name, "email": email}) + return valid, errors, len(rows) + + +@router.post("/events/{event_id}/participant-imports", status_code=201) +def create_participant_import(event_id: str, request: Request, file: UploadFile = File(...), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id) + content = file.file.read() + if not content: + raise HTTPException(status_code=422, detail="Import file is empty") + valid, errors, total = parse_participants(content, file.filename or "participants.csv") + existing = set(db.scalars(select(Participant.email).where(Participant.event_id == event.id, Participant.email.in_([row["email"] for row in valid]))).all()) + duplicates = [row for row in valid if row["email"] in existing] + accepted = [row for row in valid if row["email"] not in existing] + item = ParticipantImport( + organization_id=user.organization_id, + event_id=event.id, + source_filename=file.filename or "participants.csv", + status="READY", + total_rows=total, + valid_rows=len(accepted), + invalid_rows=len(errors), + duplicate_rows=len(duplicates), + validation_report={"errors": errors, "duplicates": duplicates}, + normalized_rows=accepted, + created_by=user.id, + ) + db.add(item) + db.flush() + add_audit(db, user, "participant_import.validated", f"{item.id}: {len(accepted)} valid, {len(errors)} invalid, {len(duplicates)} duplicate") + db.commit() + return ok({"id": item.id, "status": item.status, "total_rows": total, "valid_rows": len(accepted), "invalid_rows": len(errors), "duplicate_rows": len(duplicates), "errors": errors, "duplicates": duplicates}, request) + + +@router.get("/events/{event_id}/participant-imports") +def list_participant_imports(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + rows = db.scalars(select(ParticipantImport).where(ParticipantImport.event_id == event_id, ParticipantImport.organization_id == user.organization_id).order_by(ParticipantImport.created_at.desc())).all() + return ok([{"id": row.id, "filename": row.source_filename, "status": row.status, "total_rows": row.total_rows, "valid_rows": row.valid_rows, "invalid_rows": row.invalid_rows, "duplicate_rows": row.duplicate_rows, "created_at": row.created_at.isoformat()} for row in rows], request) + + +@router.get("/events/{event_id}/participant-imports/{import_id}") +def get_participant_import(event_id: str, import_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + row = db.scalar(select(ParticipantImport).where(ParticipantImport.id == import_id, ParticipantImport.event_id == event_id, ParticipantImport.organization_id == user.organization_id)) + if not row: + raise HTTPException(status_code=404, detail="Participant import was not found") + return ok({"id": row.id, "status": row.status, "total_rows": row.total_rows, "valid_rows": row.valid_rows, "invalid_rows": row.invalid_rows, "duplicate_rows": row.duplicate_rows, "validation_report": row.validation_report}, request) + + +@router.post("/events/{event_id}/participant-imports/{import_id}/confirm", status_code=201) +def confirm_participant_import(event_id: str, import_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id) + record = reserve_idempotency(db, user, idempotency_key, f"confirm-import:{import_id}") + if record and record.response_body: + return record.response_body + item = db.scalar(select(ParticipantImport).where(ParticipantImport.id == import_id, ParticipantImport.event_id == event.id, ParticipantImport.organization_id == user.organization_id).with_for_update()) + if not item: + raise HTTPException(status_code=404, detail="Participant import was not found") + if item.status == "CONFIRMED": + raise HTTPException(status_code=409, detail="Participant import is already confirmed") + created, invitations = [], [] + for row in item.normalized_rows or []: + raw_token, token_hash = new_opaque_token() + participant = Participant(organization_id=user.organization_id, event_id=event.id, name=row["name"], email=row["email"], enrollment_status="invited", delivery_status="pending", enrollment_token_hash=token_hash, enrollment_expires_at=utcnow() + timedelta(days=settings.enrollment_token_days)) + db.add(participant) + db.flush() + db.add(ParticipantEnrollmentToken(participant_id=participant.id, token_hash=token_hash, expires_at=participant.enrollment_expires_at)) + url = f"{settings.frontend_url}/enroll/{raw_token}" + mail = queue_email(db, user.organization_id, participant.email, f"Find your photos from {event.name}", f"

Find My Photos

") + dispatch_email(db, mail) + created.append(participant.id) + if settings.environment == "development": + invitations.append({"participant_id": participant.id, "url": url}) + item.status = "CONFIRMED" + item.confirmed_at = utcnow() + add_audit(db, user, "participant_import.confirmed", f"{item.id}: {len(created)} participants") + result = ok({"import_id": item.id, "participants_created": len(created), "development_invitations": invitations}, request) + if record: + record.response_status = 201 + record.response_body = result + db.commit() + return result + + +@router.get("/events/{event_id}/participants") +def participants(event_id: str, request: Request, page: int = 1, page_size: int = 50, status: str | None = None, search: str | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + offset, limit = pagination(page, page_size) + filters = [Participant.event_id == event_id, Participant.organization_id == user.organization_id] + if status: + filters.append(Participant.enrollment_status == status) + if search: + filters.append((Participant.name.ilike(f"%{search}%")) | (Participant.email.ilike(f"%{search}%"))) + total = db.scalar(select(func.count(Participant.id)).where(*filters)) or 0 + rows = db.scalars(select(Participant).where(*filters).order_by(Participant.created_at.desc()).offset(offset).limit(limit)).all() + return ok([{"id": row.id, "name": row.name, "email": row.email, "enrollment_status": row.enrollment_status, "delivery_status": row.delivery_status, "created_at": row.created_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + + +def tenant_participant(db: Session, user: User, event_id: str, participant_id: str) -> Participant: + tenant_event(db, user, event_id) + item = db.scalar(select(Participant).where(Participant.id == participant_id, Participant.event_id == event_id, Participant.organization_id == user.organization_id)) + if not item: + raise HTTPException(status_code=404, detail="Participant was not found") + return item + + +@router.get("/events/{event_id}/participants/{participant_id}") +def participant_detail(event_id: str, participant_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + item = tenant_participant(db, user, event_id, participant_id) + matches = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.participant_id == item.id, FaceMatch.state.in_(["high", "approved"]))) or 0 + return ok({"id": item.id, "name": item.name, "email": item.email, "enrollment_status": item.enrollment_status, "delivery_status": item.delivery_status, "matches": matches}, request) + + +@router.patch("/events/{event_id}/participants/{participant_id}") +def update_participant(event_id: str, participant_id: str, payload: ParticipantInput, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + item = tenant_participant(db, user, event_id, participant_id) + item.name = payload.name.strip() + item.email = str(payload.email).lower() + add_audit(db, user, "participant.updated", item.email) + db.commit() + return ok({"id": item.id, "name": item.name, "email": item.email}, request) + + +@router.delete("/events/{event_id}/participants/{participant_id}", status_code=204) +def delete_participant(event_id: str, participant_id: str, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + item = tenant_participant(db, user, event_id, participant_id) + if item.enrollment: + storage.delete(item.enrollment.storage_key) + user.organization.storage_used_bytes = max(0, user.organization.storage_used_bytes - item.enrollment.size_bytes) + db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event_id, operation="DELETE", bytes=-item.enrollment.size_bytes)) + add_audit(db, user, "participant.deleted", item.email) + db.delete(item) + db.commit() + + +def send_participant_invite(db: Session, participant: Participant, user: User) -> str: + db.query(ParticipantEnrollmentToken).filter(ParticipantEnrollmentToken.participant_id == participant.id, ParticipantEnrollmentToken.consumed_at.is_(None), ParticipantEnrollmentToken.revoked_at.is_(None)).update({"revoked_at": utcnow()}) + raw_token, token_hash = new_opaque_token() + expires = utcnow() + timedelta(days=settings.enrollment_token_days) + participant.enrollment_token_hash = token_hash + participant.enrollment_expires_at = expires + participant.enrollment_status = "invited" + db.add(ParticipantEnrollmentToken(participant_id=participant.id, token_hash=token_hash, expires_at=expires)) + url = f"{settings.frontend_url}/enroll/{raw_token}" + mail = queue_email(db, participant.organization_id, participant.email, f"Find your photos from {participant.event.name}", f"

Find My Photos

") + dispatch_email(db, mail) + add_audit(db, user, "participant.invitation.sent", participant.email) + return url + + +@router.post("/events/{event_id}/participants/{participant_id}/send-invite") +@router.post("/events/{event_id}/participants/{participant_id}/resend-invite") +def participant_send_invite(event_id: str, participant_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + item = tenant_participant(db, user, event_id, participant_id) + url = send_participant_invite(db, item, user) + db.commit() + data = {"status": "QUEUED"} + if settings.environment == "development": + data["development_enrollment_url"] = url + return ok(data, request) + + +@router.post("/events/{event_id}/participants", status_code=201) +def create_participant(event_id: str, payload: ParticipantInput, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id) + raw_token, token_hash = new_opaque_token() + item = Participant(organization_id=user.organization_id, event_id=event.id, name=payload.name.strip(), email=str(payload.email).lower(), enrollment_status="invited", delivery_status="pending", enrollment_token_hash=token_hash, enrollment_expires_at=utcnow() + timedelta(days=settings.enrollment_token_days)) + db.add(item) + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="Participant email already exists in this event") from exc + url = f"{settings.frontend_url}/enroll/{raw_token}" + db.add(ParticipantEnrollmentToken(participant_id=item.id, token_hash=token_hash, expires_at=item.enrollment_expires_at)) + mail = queue_email(db, user.organization_id, item.email, f"Find your photos from {event.name}", f"

Find My Photos

") + dispatch_email(db, mail) + add_audit(db, user, "participant.created", item.email) + db.commit() + data = {"id": item.id, "name": item.name, "email": item.email, "enrollment_status": item.enrollment_status} + if settings.environment == "development": + data["development_enrollment_url"] = url + return ok(data, request) + + +@router.post("/events/{event_id}/upload-batches", status_code=201) +def create_upload_batch(event_id: str, payload: UploadBatchInput, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id, lock=True) + active_reserved = db.scalar(select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where(StorageReservation.organization_id == user.organization_id, StorageReservation.status == "RESERVED", StorageReservation.expires_at > utcnow())) or 0 + if payload.reserved_bytes > settings.max_upload_bytes: + raise HTTPException(status_code=413, detail="Upload batch exceeds the configured maximum") + if user.organization.storage_used_bytes + active_reserved + payload.reserved_bytes > user.organization.storage_limit_bytes: + raise HTTPException(status_code=413, detail="Organization storage quota would be exceeded") + batch = UploadBatch(organization_id=user.organization_id, event_id=event.id, expected_files=payload.expected_files, reserved_bytes=payload.reserved_bytes, created_by=user.id, status="CREATED") + db.add(batch) + db.flush() + reservation = StorageReservation(organization_id=user.organization_id, event_id=event.id, upload_batch_id=batch.id, bytes=payload.reserved_bytes, status="RESERVED", expires_at=utcnow() + timedelta(minutes=settings.upload_reservation_minutes)) + db.add(reservation) + db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event.id, operation="RESERVE", bytes=payload.reserved_bytes)) + add_audit(db, user, "upload_batch.created", f"{batch.id}: {payload.reserved_bytes} bytes") + db.commit() + return ok({"id": batch.id, "status": batch.status, "reserved_bytes": batch.reserved_bytes, "reservation_expires_at": reservation.expires_at.isoformat()}, request) + + +@router.get("/events/{event_id}/upload-batches") +def upload_batches(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + rows = db.scalars(select(UploadBatch).where(UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id).order_by(UploadBatch.created_at.desc())).all() + return ok([{"id": row.id, "status": row.status, "expected_files": row.expected_files, "uploaded_files": row.uploaded_files, "reserved_bytes": row.reserved_bytes, "committed_bytes": row.committed_bytes, "created_at": row.created_at.isoformat(), "completed_at": row.completed_at.isoformat() if row.completed_at else None} for row in rows], request) + + +@router.get("/events/{event_id}/upload-batches/{batch_id}") +def upload_batch_detail(event_id: str, batch_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + row = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id)) + if not row: + raise HTTPException(status_code=404, detail="Upload batch was not found") + return ok({"id": row.id, "status": row.status, "expected_files": row.expected_files, "uploaded_files": row.uploaded_files, "reserved_bytes": row.reserved_bytes, "committed_bytes": row.committed_bytes, "manifest": row.manifest}, request) + + +@router.post("/events/{event_id}/upload-batches/{batch_id}/presign") +def presign_uploads(event_id: str, batch_id: str, payload: PresignInput, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id).with_for_update()) + if not batch or batch.status not in {"CREATED", "UPLOADING"}: + raise HTTPException(status_code=409, detail="Upload batch cannot accept files") + total = sum(item.size_bytes for item in payload.files) + if total > batch.reserved_bytes: + raise HTTPException(status_code=413, detail="Files exceed reserved upload bytes") + manifest, urls = [], [] + for item in payload.files: + if item.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=422, detail=f"Unsupported media type for {item.filename}") + media_id = str(uuid.uuid4()) + extension = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[item.content_type] + key = f"organizations/{user.organization_id}/events/{event_id}/media/original/{media_id}{extension}" + upload_url = storage.presign_put(key, item.content_type) + if not upload_url: + upload_url = f"/api/v2/events/{event_id}/upload-batches/{batch.id}/objects/{media_id}" + record = {"media_id": media_id, "filename": item.filename, "content_type": item.content_type, "size_bytes": item.size_bytes, "sha256": item.sha256.lower(), "storage_key": key} + manifest.append(record) + urls.append({**record, "upload_url": upload_url, "method": "PUT", "headers": {"Content-Type": item.content_type}}) + batch.manifest = manifest + batch.status = "UPLOADING" + db.commit() + return ok({"batch_id": batch.id, "files": urls, "expires_in": 900}, request) + + +@router.put("/events/{event_id}/upload-batches/{batch_id}/objects/{media_id}", status_code=204) +async def local_upload_object(event_id: str, batch_id: str, media_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + if settings.storage_backend == "s3": + raise HTTPException(status_code=404, detail="Direct local upload endpoint is disabled") + tenant_event(db, user, event_id) + batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id)) + record = next((row for row in batch.manifest or [] if row["media_id"] == media_id), None) if batch else None + if not record: + raise HTTPException(status_code=404, detail="Upload object was not found") + content = await request.body() + if len(content) != record["size_bytes"] or hashlib.sha256(content).hexdigest() != record["sha256"]: + raise HTTPException(status_code=422, detail="Uploaded object size or checksum does not match manifest") + storage.put(record["storage_key"], content, record["content_type"]) + + +@router.post("/events/{event_id}/upload-batches/{batch_id}/complete", status_code=202) +def complete_upload_batch(event_id: str, batch_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_member), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id, lock=True) + idem = reserve_idempotency(db, user, idempotency_key, f"complete-upload:{batch_id}") + if idem and idem.response_body: + return idem.response_body + batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event.id, UploadBatch.organization_id == user.organization_id).with_for_update()) + if not batch or batch.status not in {"UPLOADING", "VERIFYING"}: + raise HTTPException(status_code=409, detail="Upload batch cannot be completed") + batch.status = "VERIFYING" + committed, jobs = 0, [] + for record in batch.manifest or []: + try: + stat = storage.stat(record["storage_key"]) + except FileNotFoundError as exc: + raise HTTPException(status_code=409, detail=f"Upload is incomplete: {record['filename']}") from exc + if stat["size"] != record["size_bytes"]: + raise HTTPException(status_code=422, detail=f"Uploaded size mismatch: {record['filename']}") + if db.scalar(select(Photo.id).where(Photo.event_id == event.id, Photo.sha256 == record["sha256"])): + storage.delete(record["storage_key"]) + continue + photo = Photo(id=record["media_id"], organization_id=user.organization_id, event_id=event.id, filename=record["filename"], storage_key=record["storage_key"], content_type=record["content_type"], size_bytes=record["size_bytes"], sha256=record["sha256"], processing_status="queued") + job = ProcessingJob(organization_id=user.organization_id, event_id=event.id, photo_id=photo.id, job_type="ML_PROCESS", status="queued", correlation_id=request_id(request), max_attempts=5) + db.add_all([photo, job]) + db.flush() + add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": photo.id}, user.organization_id, request_id(request)) + db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event.id, photo_id=photo.id, operation="ADD", bytes=photo.size_bytes)) + committed += photo.size_bytes + jobs.append(job.id) + reservation = db.scalar(select(StorageReservation).where(StorageReservation.upload_batch_id == batch.id).with_for_update()) + if reservation: + reservation.status = "COMMITTED" + batch.committed_bytes = committed + batch.uploaded_files = len(jobs) + batch.status = "COMPLETE" + batch.completed_at = utcnow() + user.organization.storage_used_bytes += committed + event.status = "PROCESSING" + add_audit(db, user, "upload_batch.completed", f"{batch.id}: {len(jobs)} media, {committed} bytes") + result = ok({"batch_id": batch.id, "status": batch.status, "media_created": len(jobs), "jobs": jobs}, request) + if idem: + idem.response_status = 202 + idem.response_body = result + db.commit() + return result + + +@router.post("/events/{event_id}/upload-batches/{batch_id}/cancel") +def cancel_upload_batch(event_id: str, batch_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id).with_for_update()) + if not batch or batch.status == "COMPLETE": + raise HTTPException(status_code=409, detail="Upload batch cannot be cancelled") + for record in batch.manifest or []: + storage.delete(record["storage_key"]) + reservation = db.scalar(select(StorageReservation).where(StorageReservation.upload_batch_id == batch.id)) + if reservation: + reservation.status = "RELEASED" + db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event_id, operation="RELEASE", bytes=reservation.bytes)) + batch.status = "CANCELLED" + add_audit(db, user, "upload_batch.cancelled", batch.id) + db.commit() + return ok({"batch_id": batch.id, "status": batch.status}, request) + + +@router.get("/events/{event_id}/media") +def event_media(event_id: str, request: Request, page: int = 1, page_size: int = 50, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + offset, limit = pagination(page, page_size) + total = db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event_id, Photo.organization_id == user.organization_id)) or 0 + rows = db.scalars(select(Photo).where(Photo.event_id == event_id, Photo.organization_id == user.organization_id).order_by(Photo.uploaded_at.desc()).offset(offset).limit(limit)).all() + return ok([{"id": row.id, "filename": row.filename, "mime_type": row.content_type, "size_bytes": row.size_bytes, "sha256": row.sha256, "status": row.processing_status, "uploaded_at": row.uploaded_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + + +def tenant_photo(db: Session, user: User, event_id: str, media_id: str) -> Photo: + tenant_event(db, user, event_id) + item = db.scalar(select(Photo).where(Photo.id == media_id, Photo.event_id == event_id, Photo.organization_id == user.organization_id)) + if not item: + raise HTTPException(status_code=404, detail="Media was not found") + return item + + +@router.get("/events/{event_id}/media/{media_id}") +def media_detail(event_id: str, media_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + item = tenant_photo(db, user, event_id, media_id) + return ok({"id": item.id, "filename": item.filename, "mime_type": item.content_type, "size_bytes": item.size_bytes, "sha256": item.sha256, "status": item.processing_status, "download_url": storage.presign_get(item.storage_key) or f"/api/media/{item.id}"}, request) + + +@router.delete("/events/{event_id}/media/{media_id}", status_code=204) +def delete_media(event_id: str, media_id: str, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + item = tenant_photo(db, user, event_id, media_id) + released = item.size_bytes + item.thumbnail_size_bytes + storage.delete(item.storage_key) + if item.thumbnail_storage_key: + storage.delete(item.thumbnail_storage_key) + user.organization.storage_used_bytes = max(0, user.organization.storage_used_bytes - released) + db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event_id, operation="DELETE", bytes=-released)) + add_audit(db, user, "media.deleted", item.filename) + db.delete(item) + db.commit() + + +@router.post("/events/{event_id}/media/{media_id}/reprocess", status_code=202) +def reprocess_media(event_id: str, media_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + item = tenant_photo(db, user, event_id, media_id) + job = ProcessingJob(organization_id=user.organization_id, event_id=event_id, photo_id=item.id, job_type="ML_PROCESS", status="queued", correlation_id=request_id(request)) + db.add(job) + db.flush() + add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": item.id}, user.organization_id, request_id(request)) + item.processing_status = "queued" + add_audit(db, user, "media.reprocess_requested", item.filename) + db.commit() + return ok({"job_id": job.id, "status": "QUEUED"}, request) + + +@router.post("/events/{event_id}/start-processing", status_code=202) +def start_processing(event_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id, lock=True) + idem = reserve_idempotency(db, user, idempotency_key, f"start-processing:{event_id}") + if idem and idem.response_body: + return idem.response_body + queued = db.scalars(select(ProcessingJob).where(ProcessingJob.event_id == event.id, ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]))).all() + if not queued: + raise HTTPException(status_code=409, detail="No media is queued for processing") + event.status = "PROCESSING" + for job in queued: + add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": job.photo_id}, user.organization_id, request_id(request)) + add_audit(db, user, "processing.started", f"{event.name}: {len(queued)} jobs") + result = ok({"event_id": event.id, "status": event.status, "jobs_queued": len(queued)}, request) + if idem: + idem.response_status = 202 + idem.response_body = result + db.commit() + return result + + +@router.get("/events/{event_id}/processing") +def processing_summary(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id) + statuses = dict(db.execute(select(ProcessingJob.status, func.count(ProcessingJob.id)).where(ProcessingJob.event_id == event.id).group_by(ProcessingJob.status)).all()) + photos_total = db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event.id)) or 0 + photos_processed = db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event.id, Photo.processing_status == "ready")) or 0 + faces = db.scalar(select(func.count(FaceDetection.id)).where(FaceDetection.event_id == event.id)) or 0 + decisions = dict(db.execute(select(FaceMatch.state, func.count(FaceMatch.id)).where(FaceMatch.event_id == event.id).group_by(FaceMatch.state)).all()) + return ok({"event_state": event.status, "photos_total": photos_total, "photos_processed": photos_processed, "photos_failed": statuses.get("failed", 0) + statuses.get("DEAD_LETTERED", 0), "faces_detected": faces, "matches_auto": decisions.get("high", 0) + decisions.get("approved", 0), "matches_review": decisions.get("review", 0), "matches_unknown": decisions.get("low", 0) + decisions.get("rejected", 0), "progress_percent": round(photos_processed * 100 / photos_total) if photos_total else 0}, request) + + +@router.get("/events/{event_id}/processing/jobs") +def processing_jobs(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + rows = db.scalars(select(ProcessingJob).where(ProcessingJob.event_id == event_id, ProcessingJob.organization_id == user.organization_id).order_by(ProcessingJob.created_at.desc())).all() + return ok([{"id": row.id, "photo_id": row.photo_id, "job_type": row.job_type, "status": row.status, "attempt": row.attempt, "max_attempts": row.max_attempts, "progress_current": row.progress_current, "progress_total": row.progress_total, "error_code": "PROCESSING_FAILED" if row.error else None, "error_message": row.error, "queued_at": row.created_at.isoformat(), "started_at": row.started_at.isoformat() if row.started_at else None, "finished_at": row.completed_at.isoformat() if row.completed_at else None} for row in rows], request) + + +@router.get("/events/{event_id}/processing/jobs/{job_id}") +def processing_job_detail(event_id: str, job_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + row = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.event_id == event_id, ProcessingJob.organization_id == user.organization_id)) + if not row: + raise HTTPException(status_code=404, detail="Processing job was not found") + return ok({"id": row.id, "photo_id": row.photo_id, "job_type": row.job_type, "status": row.status, "attempt": row.attempt, "max_attempts": row.max_attempts, "progress_current": row.progress_current, "progress_total": row.progress_total, "error_message": row.error, "heartbeat_at": row.heartbeat_at.isoformat() if row.heartbeat_at else None}, request) + + +@router.post("/events/{event_id}/processing/jobs/{job_id}/retry", status_code=202) +def retry_processing_job(event_id: str, job_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + row = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.event_id == event_id, ProcessingJob.organization_id == user.organization_id).with_for_update()) + if not row or row.status not in {"failed", "FAILED", "DEAD_LETTERED"}: + raise HTTPException(status_code=409, detail="Processing job is not retryable") + row.status = "queued" + row.error = None + row.next_attempt_at = utcnow() + add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", row.id, {"job_id": row.id, "media_id": row.photo_id}, user.organization_id, request_id(request)) + add_audit(db, user, "processing.retry", row.id) + db.commit() + return ok({"job_id": row.id, "status": "QUEUED"}, request) + + +@router.get("/events/{event_id}/matches") +def event_matches(event_id: str, request: Request, decision: str | None = None, participant_id: str | None = None, minimum_score: float | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + filters = [FaceMatch.event_id == event_id, FaceMatch.organization_id == user.organization_id] + if decision: + filters.append(FaceMatch.state == decision) + if participant_id: + filters.append(FaceMatch.participant_id == participant_id) + if minimum_score is not None: + filters.append(FaceMatch.confidence >= minimum_score) + rows = db.scalars(select(FaceMatch).where(*filters).order_by(FaceMatch.created_at.desc()).limit(500)).all() + return ok([{"id": row.id, "participant_id": row.participant_id, "media_id": row.detection.photo_id, "similarity_score": row.confidence, "second_best_score": row.second_best_score, "margin": row.margin, "decision": row.state, "decision_source": row.decision_source, "model_name": row.model_name, "model_version": row.model_version, "threshold_profile_version": row.threshold_profile_version} for row in rows], request) + + +def review_match(event_id: str, match_id: str, target: str, request: Request, user: User, db: Session): + tenant_event(db, user, event_id) + row = db.scalar(select(FaceMatch).where(FaceMatch.id == match_id, FaceMatch.event_id == event_id, FaceMatch.organization_id == user.organization_id).with_for_update()) + if not row: + raise HTTPException(status_code=404, detail="Match was not found") + row.state = target + row.decision_source = "MANUAL_CONFIRM" if target == "approved" else "MANUAL_REJECT" + row.reviewed_by = user.id + row.reviewed_at = utcnow() + add_audit(db, user, f"match.{target}", row.id) + db.commit() + return ok({"id": row.id, "decision": row.state, "decision_source": row.decision_source}, request) + + +@router.post("/events/{event_id}/matches/{match_id}/confirm") +def confirm_match(event_id: str, match_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + return review_match(event_id, match_id, "approved", request, user, db) + + +@router.post("/events/{event_id}/matches/{match_id}/reject") +def reject_match(event_id: str, match_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + return review_match(event_id, match_id, "rejected", request, user, db) + + +@router.post("/events/{event_id}/galleries/build", status_code=202) +def build_galleries(event_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id, lock=True) + idem = reserve_idempotency(db, user, idempotency_key, f"build-galleries:{event_id}") + if idem and idem.response_body: + return idem.response_body + participant_ids = db.scalars(select(FaceMatch.participant_id).where(FaceMatch.event_id == event.id, FaceMatch.organization_id == user.organization_id, FaceMatch.participant_id.is_not(None), FaceMatch.state.in_(["high", "approved"])).distinct()).all() + created = 0 + for participant_id in participant_ids: + delivery = db.scalar(select(Delivery).where(Delivery.event_id == event.id, Delivery.participant_id == participant_id)) + if not delivery: + _, placeholder_hash = new_opaque_token() + delivery = Delivery(organization_id=user.organization_id, event_id=event.id, participant_id=participant_id, gallery_token_hash=placeholder_hash, status="ready", expires_at=datetime.combine(event.expires_at, datetime.min.time(), timezone.utc)) + db.add(delivery) + created += 1 + add_outbox(db, "fdx.v2.gallery.build.requested", "participant", participant_id, {"participant_id": participant_id, "event_id": event.id}, user.organization_id, request_id(request)) + event.status = "READY_TO_DELIVER" + add_audit(db, user, "gallery.build_requested", f"{event.name}: {len(participant_ids)} participants") + result = ok({"event_id": event.id, "galleries_ready": len(participant_ids), "galleries_created": created}, request) + if idem: + idem.response_status = 202 + idem.response_body = result + db.commit() + return result + + +@router.get("/events/{event_id}/galleries") +def list_galleries(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + rows = db.scalars(select(Delivery).where(Delivery.event_id == event_id, Delivery.organization_id == user.organization_id).order_by(Delivery.created_at.desc())).all() + return ok([{"id": row.id, "participant_id": row.participant_id, "participant_name": row.participant.name, "status": row.status.upper(), "access_expires_at": row.expires_at.isoformat(), "created_at": row.created_at.isoformat()} for row in rows], request) + + +@router.get("/events/{event_id}/galleries/{gallery_id}") +def gallery_detail(event_id: str, gallery_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + row = db.scalar(select(Delivery).where(Delivery.id == gallery_id, Delivery.event_id == event_id, Delivery.organization_id == user.organization_id)) + if not row: + raise HTTPException(status_code=404, detail="Gallery was not found") + count = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.event_id == event_id, FaceMatch.participant_id == row.participant_id, FaceMatch.state.in_(["high", "approved"]))) or 0 + return ok({"id": row.id, "participant_id": row.participant_id, "participant_name": row.participant.name, "status": row.status.upper(), "photos": count, "access_expires_at": row.expires_at.isoformat()}, request) + + +def deliver_gallery(db: Session, delivery: Delivery, user: User) -> str: + raw_token, token_hash = new_opaque_token() + delivery.gallery_token_hash = token_hash + delivery.status = "ready" + gallery_url = f"{settings.frontend_url}/gallery/{raw_token}" + count = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.event_id == delivery.event_id, FaceMatch.participant_id == delivery.participant_id, FaceMatch.state.in_(["high", "approved"]))) or 0 + mail = queue_email(db, delivery.organization_id, delivery.participant.email, f"Your photos from {delivery.event.name} are ready", f"

We found {count} photos containing you.

View My Photos

", delivery_id=delivery.id) + dispatch_email(db, mail) + add_audit(db, user, "gallery.delivered", delivery.participant.email) + return gallery_url + + +@router.post("/events/{event_id}/deliveries/send", status_code=202) +def send_deliveries(event_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + event = tenant_event(db, user, event_id, lock=True) + idem = reserve_idempotency(db, user, idempotency_key, f"send-deliveries:{event_id}") + if idem and idem.response_body: + return idem.response_body + rows = db.scalars(select(Delivery).where(Delivery.event_id == event.id, Delivery.organization_id == user.organization_id)).all() + development_urls = [] + for row in rows: + url = deliver_gallery(db, row, user) + add_outbox(db, "fdx.v2.email.send.requested", "delivery", row.id, {"delivery_id": row.id}, user.organization_id, request_id(request)) + if settings.environment == "development": + development_urls.append({"participant_id": row.participant_id, "url": url}) + event.status = "DELIVERING" if rows else event.status + result = ok({"event_id": event.id, "deliveries_queued": len(rows), "development_gallery_urls": development_urls}, request) + if idem: + idem.response_status = 202 + idem.response_body = result + db.commit() + return result + + +@router.get("/events/{event_id}/deliveries") +def list_deliveries(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): + tenant_event(db, user, event_id) + rows = db.scalars(select(Delivery).where(Delivery.event_id == event_id, Delivery.organization_id == user.organization_id).order_by(Delivery.created_at.desc())).all() + return ok([{"id": row.id, "participant_id": row.participant_id, "participant_name": row.participant.name, "status": row.status.upper(), "sent_at": row.sent_at.isoformat() if row.sent_at else None, "expires_at": row.expires_at.isoformat()} for row in rows], request) + + +@router.post("/events/{event_id}/participants/{participant_id}/resend-results") +def resend_results(event_id: str, participant_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): + tenant_participant(db, user, event_id, participant_id) + delivery = db.scalar(select(Delivery).where(Delivery.event_id == event_id, Delivery.participant_id == participant_id, Delivery.organization_id == user.organization_id)) + if not delivery: + raise HTTPException(status_code=409, detail="Participant gallery has not been built") + url = deliver_gallery(db, delivery, user) + db.commit() + data = {"status": "QUEUED"} + if settings.environment == "development": + data["development_gallery_url"] = url + return ok(data, request) + + +@router.get("/public/enrollment/{token}") +def public_enrollment(token: str, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "enrollment-read", token, 30) + token_row = db.scalar(select(ParticipantEnrollmentToken).where(ParticipantEnrollmentToken.token_hash == hash_token(token)).with_for_update()) + participant = db.get(Participant, token_row.participant_id) if token_row else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + expires_at = token_row.expires_at if token_row else participant.enrollment_expires_at if participant else None + if not participant or not expires_at or expires_at <= utcnow() or (token_row and (token_row.revoked_at or token_row.consumed_at)): + raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") + if token_row and not token_row.opened_at: + token_row.opened_at = utcnow() + participant.enrollment_status = "opened" + db.commit() + return ok({"organization_name": participant.event.organization.name, "event_name": participant.event.name, "participant_name": participant.name, "status": participant.enrollment_status, "expires_at": expires_at.isoformat(), "purpose": "Find event photographs containing you", "retention_days": participant.event.retention_days, "consent_policy_version": settings.consent_policy_version}, request) + + +@router.post("/public/enrollment/{token}/consent", status_code=201) +def enrollment_consent(token: str, request: Request, accepted: bool = Form(...), db: Session = Depends(get_db)): + check_public_rate_limit(request, "enrollment-consent", token, 10, 300) + token_row = db.scalar(select(ParticipantEnrollmentToken).where(ParticipantEnrollmentToken.token_hash == hash_token(token))) + participant = db.get(Participant, token_row.participant_id) if token_row else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + expires_at = token_row.expires_at if token_row else participant.enrollment_expires_at if participant else None + if not participant or not expires_at or expires_at <= utcnow() or (token_row and (token_row.revoked_at or token_row.consumed_at)): + raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") + if not accepted: + raise HTTPException(status_code=422, detail="Consent is required") + consent = Consent(organization_id=participant.organization_id, event_id=participant.event_id, participant_id=participant.id, consent_type="face_enrollment", policy_version=settings.consent_policy_version, accepted=True, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent")) + db.add(consent) + db.commit() + return ok({"consent_id": consent.id, "policy_version": consent.policy_version, "accepted_at": consent.accepted_at.isoformat()}, request) + + +@router.post("/public/enrollment/{token}/complete") +def complete_enrollment(token: str, request: Request, selfie: UploadFile = File(...), db: Session = Depends(get_db)): + check_public_rate_limit(request, "enrollment-complete", token, 10, 300) + token_row = db.scalar(select(ParticipantEnrollmentToken).where(ParticipantEnrollmentToken.token_hash == hash_token(token)).with_for_update()) + participant = db.get(Participant, token_row.participant_id) if token_row else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + expires_at = token_row.expires_at if token_row else participant.enrollment_expires_at if participant else None + if not participant or not expires_at or expires_at <= utcnow() or (token_row and (token_row.revoked_at or token_row.consumed_at)): + raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") + consent = db.scalar(select(Consent).where(Consent.participant_id == participant.id, Consent.accepted.is_(True)).order_by(Consent.accepted_at.desc())) + if not consent: + raise HTTPException(status_code=422, detail="Consent must be recorded before enrollment") + content = selfie.file.read() + content_type = selfie.content_type or "application/octet-stream" + if not content or content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=422, detail="A JPEG, PNG, or WEBP selfie is required") + try: + with Image.open(io.BytesIO(content)) as image: + image.verify() + result = ml_embedding(content, selfie.filename or "selfie.jpg", content_type) + except Exception as exc: + raise HTTPException(status_code=422, detail="A clear, usable face could not be enrolled") from exc + previous_size = participant.enrollment.size_bytes if participant.enrollment else 0 + additional = len(content) - previous_size + if participant.event.organization.storage_used_bytes + additional > participant.event.organization.storage_limit_bytes: + raise HTTPException(status_code=413, detail="Organization storage quota would be exceeded") + key = f"organizations/{participant.organization_id}/events/{participant.event_id}/enrollment/{participant.id}/{uuid.uuid4()}.jpg" + storage.put(key, content, content_type) + enrollment = participant.enrollment or FaceEnrollment(participant_id=participant.id, storage_key=key, embedding=result["embedding"], detector_confidence=result["box"]["probability"]) + enrollment.organization_id = participant.organization_id + enrollment.event_id = participant.event_id + enrollment.storage_key = key + enrollment.size_bytes = len(content) + enrollment.embedding = result["embedding"] + enrollment.embedding_vector = result["embedding"] + enrollment.embedding_dimension = len(result["embedding"]) + enrollment.detector_confidence = result["box"]["probability"] + enrollment.model_name = "adaface-ir101-ms1mv2" + enrollment.model_version = settings.embedder_model_version + enrollment.status = "valid" + enrollment.expires_at = datetime.combine(participant.event.expires_at, datetime.min.time(), timezone.utc) + db.add(enrollment) + participant.enrollment_status = "verified" + participant.consented_at = consent.accepted_at + participant.event.organization.storage_used_bytes += additional + db.add(StorageUsageLedger(organization_id=participant.organization_id, event_id=participant.event_id, operation="ADD", bytes=additional)) + if token_row: + token_row.consumed_at = utcnow() + add_audit(db, None, "enrollment.completed", participant.email, participant.organization_id) + db.commit() + return ok({"status": "ENROLLED", "embedding_dimension": enrollment.embedding_dimension, "model_name": enrollment.model_name, "model_version": enrollment.model_version}, request) + + +@router.get("/public/gallery/{token}") +def public_gallery(token: str, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "gallery-read", token, 60) + delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) + if not delivery or delivery.expires_at <= utcnow(): + raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") + rows = db.scalars(select(FaceMatch).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceMatch.state.in_(["high", "approved"]))).all() + photos = {row.detection.photo.id: row.detection.photo for row in rows} + data = [] + for photo in photos.values(): + signed = storage.presign_get(photo.thumbnail_storage_key or photo.storage_key) + data.append({"id": photo.id, "filename": photo.filename, "thumbnail_url": signed or f"/api/public/gallery/{token}/photos/{photo.id}/thumbnail", "download_endpoint": f"/api/v2/public/gallery/{token}/download-url"}) + return ok({"event_name": delivery.event.name, "organization_name": delivery.event.organization.name, "expires_at": delivery.expires_at.isoformat(), "photos": data}, request) + + +class DownloadInput(BaseModel): + media_id: str + + +@router.post("/public/gallery/{token}/download-url") +def gallery_download_url(token: str, payload: DownloadInput, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "gallery-download", token, 60) + delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) + if not delivery or delivery.expires_at <= utcnow(): + raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") + match = db.scalar(select(FaceMatch).join(FaceDetection).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceDetection.photo_id == payload.media_id, FaceMatch.state.in_(["high", "approved"]))) + if not match: + raise HTTPException(status_code=404, detail="Photo was not found") + photo = match.detection.photo + url = storage.presign_get(photo.storage_key, filename=photo.filename) or f"/api/public/gallery/{token}/photos/{photo.id}" + return ok({"url": url, "expires_in": 600}, request) + + +@router.post("/public/gallery/{token}/exports", status_code=202) +def create_gallery_export(token: str, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "gallery-export", token, 5, 300) + delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) + if not delivery or delivery.expires_at <= utcnow(): + raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") + existing = db.scalar(select(GalleryExport).where( + GalleryExport.participant_id == delivery.participant_id, + GalleryExport.event_id == delivery.event_id, + GalleryExport.status.in_(["QUEUED", "PROCESSING", "READY"]), + GalleryExport.expires_at > utcnow(), + ).order_by(GalleryExport.created_at.desc())) + if existing: + return ok({"export_id": existing.id, "status": existing.status}, request) + job = ProcessingJob( + organization_id=delivery.organization_id, + event_id=delivery.event_id, + job_type="GALLERY_EXPORT", + status="queued", + correlation_id=request_id(request), + max_attempts=5, + ) + db.add(job) + db.flush() + export = GalleryExport( + organization_id=delivery.organization_id, + event_id=delivery.event_id, + participant_id=delivery.participant_id, + processing_job_id=job.id, + expires_at=min(delivery.expires_at, utcnow() + timedelta(hours=24)), + ) + db.add(export) + db.flush() + add_outbox(db, "fdx.v2.gallery.export.requested", "gallery_export", export.id, {"job_id": job.id, "export_id": export.id}, delivery.organization_id, request_id(request)) + db.commit() + return ok({"export_id": export.id, "status": export.status}, request) + + +@router.get("/public/gallery/{token}/exports/{export_id}") +def gallery_export_status(token: str, export_id: str, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "gallery-export-status", token, 60) + delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) + if not delivery or delivery.expires_at <= utcnow(): + raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") + item = db.scalar(select(GalleryExport).where( + GalleryExport.id == export_id, + GalleryExport.event_id == delivery.event_id, + GalleryExport.participant_id == delivery.participant_id, + )) + if not item or item.expires_at <= utcnow(): + raise HTTPException(status_code=404, detail="Gallery export was not found or has expired") + url = storage.presign_get(item.storage_key, filename=f"{delivery.event.name}-photos.zip") if item.status == "READY" and item.storage_key else None + if item.status == "READY" and not url: + url = f"/api/v2/public/gallery/{token}/exports/{item.id}/download" + return ok({"export_id": item.id, "status": item.status, "size_bytes": item.size_bytes, "download_url": url, "expires_at": item.expires_at.isoformat(), "error": item.error}, request) + + +@router.get("/public/gallery/{token}/exports/{export_id}/download") +def download_gallery_export(token: str, export_id: str, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "gallery-export-download", token, 20, 300) + delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) + item = db.scalar(select(GalleryExport).where(GalleryExport.id == export_id)) + if not delivery or delivery.expires_at <= utcnow() or not item or item.participant_id != delivery.participant_id or item.event_id != delivery.event_id or item.status != "READY" or item.expires_at <= utcnow() or not item.storage_key: + raise HTTPException(status_code=404, detail="Gallery export was not found or has expired") + content, _ = storage.read(item.storage_key) + filename = "".join(character if character.isalnum() or character in "-_" else "-" for character in delivery.event.name).strip("-") or "fdx-gallery" + return Response(content=content, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}.zip"', "Cache-Control": "private, no-store"}) + + +def verify_webhook_signature(body: bytes, signature: str | None) -> None: + if not settings.email_webhook_secret: + raise HTTPException(status_code=503, detail="Email webhook verification is not configured") + expected = hmac.new(settings.email_webhook_secret.encode(), body, hashlib.sha256).hexdigest() + supplied = (signature or "").removeprefix("sha256=") + if not supplied or not hmac.compare_digest(expected, supplied): + raise HTTPException(status_code=401, detail="Webhook signature is invalid") + + +async def process_email_webhook(provider: str, request: Request, signature: str | None, db: Session): + if settings.email_provider != provider: + raise HTTPException(status_code=404, detail="Webhook provider is not enabled") + body = await request.body() + verify_webhook_signature(body, signature) + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail="Webhook body is not valid JSON") from exc + provider_event_id = str(payload.get("id") or payload.get("event_id") or "") + if not provider_event_id: + raise HTTPException(status_code=422, detail="Webhook event ID is required") + if db.scalar(select(WebhookEvent.id).where(WebhookEvent.provider == provider, WebhookEvent.provider_event_id == provider_event_id)): + return ok({"status": "DUPLICATE"}, request) + db.add(WebhookEvent(provider=provider, provider_event_id=provider_event_id, payload=payload)) + provider_message_id = payload.get("data", {}).get("email_id") or payload.get("mail", {}).get("messageId") or payload.get("message_id") + item = db.scalar(select(EmailOutbox).where(EmailOutbox.provider_id == provider_message_id)) if provider_message_id else None + if item: + event_type = str(payload.get("type") or payload.get("eventType") or "").lower() + if any(value in event_type for value in ("delivered", "delivery")): + item.status = "delivered" + elif "bounce" in event_type: + item.status = "bounced" + elif any(value in event_type for value in ("failed", "complaint", "suppressed")): + item.status = "failed" + if item.delivery_id and item.status in {"delivered", "bounced", "failed"}: + delivery = db.get(Delivery, item.delivery_id) + if delivery: + delivery.status = "delivered" if item.status == "delivered" else "failed" + delivery.participant.delivery_status = delivery.status + db.commit() + return ok({"status": "PROCESSED"}, request) + + +@router.post("/webhooks/email/resend") +async def resend_webhook(request: Request, x_fdx_webhook_signature: str | None = Header(default=None, alias="X-FDX-Webhook-Signature"), db: Session = Depends(get_db)): + return await process_email_webhook("resend", request, x_fdx_webhook_signature, db) + + +@router.post("/webhooks/email/ses") +async def ses_webhook(request: Request, x_fdx_webhook_signature: str | None = Header(default=None, alias="X-FDX-Webhook-Signature"), db: Session = Depends(get_db)): + return await process_email_webhook("ses", request, x_fdx_webhook_signature, db) diff --git a/backend/app/worker.py b/backend/app/worker.py index be3f414..61c1e52 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -1,18 +1,22 @@ from __future__ import annotations +import hashlib +import io import math import os import socket import time +import zipfile from datetime import date, datetime, timedelta, timezone from kafka import KafkaConsumer from kafka.errors import KafkaError +from PIL import Image, ImageOps from sqlalchemy import delete, func, select from .config import settings from .database import SessionLocal -from .integrations import dispatch_email, ml_faces, storage +from .integrations import dispatch_email, ml_faces, publish_event, queue_email, storage from .main import audit, bootstrap from .models import ( Delivery, @@ -21,16 +25,89 @@ FaceDetection, FaceEnrollment, FaceMatch, + GalleryExport, Organization, + OutboxEvent, Participant, Photo, ProcessingJob, + RefreshSession, + StorageReservation, + StorageUsageLedger, + UploadBatch, + User, + UserRole, utcnow, ) WORKER_NAME = f"ml-{socket.gethostname()}-{os.getpid()}" +def process_gallery_export(job_id: str) -> None: + with SessionLocal() as db: + job = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.job_type == "GALLERY_EXPORT", ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"])).with_for_update(skip_locked=True)) + if not job or (job.next_attempt_at and job.next_attempt_at > utcnow()): + return + item = db.scalar(select(GalleryExport).where(GalleryExport.processing_job_id == job.id).with_for_update()) + if not item: + return + job.status = "processing" + job.attempt += 1 + job.worker = WORKER_NAME + job.started_at = utcnow() + job.heartbeat_at = utcnow() + item.status = "PROCESSING" + db.commit() + try: + matches = db.scalars(select(FaceMatch).where( + FaceMatch.event_id == item.event_id, + FaceMatch.participant_id == item.participant_id, + FaceMatch.state.in_(["high", "approved"]), + )).all() + photos = {match.detection.photo.id: match.detection.photo for match in matches} + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as bundle: + for index, photo in enumerate(photos.values(), start=1): + content, _ = storage.read(photo.storage_key) + filename = os.path.basename(photo.filename).replace("..", "_") or f"photo-{index}.jpg" + bundle.writestr(f"{index:04d}-{photo.id[:8]}-{filename}", content) + content = archive.getvalue() + organization = db.get(Organization, item.organization_id) + if organization.storage_used_bytes + len(content) > organization.storage_limit_bytes: + raise ValueError("Organization storage quota would be exceeded by the gallery export") + key = f"organizations/{item.organization_id}/events/{item.event_id}/exports/{item.id}.zip" + storage.put(key, content, "application/zip") + item.storage_key = key + item.size_bytes = len(content) + item.status = "READY" + item.completed_at = utcnow() + item.error = None + organization.storage_used_bytes += len(content) + db.add(StorageUsageLedger(organization_id=item.organization_id, event_id=item.event_id, operation="ADD", bytes=len(content))) + job.status = "completed" + job.progress = 100 + job.progress_current = 100 + job.completed_at = utcnow() + job.heartbeat_at = utcnow() + db.commit() + except Exception as exc: + db.rollback() + job = db.get(ProcessingJob, job_id) + item = db.scalar(select(GalleryExport).where(GalleryExport.processing_job_id == job_id)) + retry_delays = [0, 30, 120, 600, 1800] + retryable = bool(job and job.attempt < job.max_attempts) + if job: + job.status = "RETRY_SCHEDULED" if retryable else "DEAD_LETTERED" + job.error = str(exc) + job.next_attempt_at = utcnow() + timedelta(seconds=retry_delays[min(job.attempt, len(retry_delays) - 1)]) if retryable else None + job.completed_at = None if retryable else utcnow() + if item: + item.status = "QUEUED" if retryable else "FAILED" + item.error = str(exc) + audit(db, None, "Gallery export failed", f"{job_id}: {exc}", "danger", item.organization_id if item else None) + db.commit() + + def cosine(left: list[float], right: list[float]) -> float: dot = sum(a * b for a, b in zip(left, right)) norm_left = math.sqrt(sum(value * value for value in left)) @@ -40,40 +117,111 @@ def cosine(left: list[float], right: list[float]) -> float: def process_job(job_id: str) -> None: with SessionLocal() as db: - job = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.status == "queued").with_for_update(skip_locked=True)) + job_type = db.scalar(select(ProcessingJob.job_type).where(ProcessingJob.id == job_id)) + if job_type == "GALLERY_EXPORT": + process_gallery_export(job_id) + return + with SessionLocal() as db: + job = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"])).with_for_update(skip_locked=True)) if not job: return + if job.next_attempt_at and job.next_attempt_at > utcnow(): + return job.status = "processing" job.progress = 10 + job.progress_current = 10 + job.attempt += 1 job.worker = WORKER_NAME job.started_at = utcnow() + job.heartbeat_at = utcnow() photo = db.get(Photo, job.photo_id) photo.processing_status = "processing" db.commit() try: content, content_type = storage.read(photo.storage_key) + if hashlib.sha256(content).hexdigest() != photo.sha256: + raise ValueError("Media checksum does not match the verified upload manifest") + with Image.open(io.BytesIO(content)) as source: + detected_type = Image.MIME.get(source.format) + source.verify() + if detected_type not in {"image/jpeg", "image/png", "image/webp"} or detected_type != photo.content_type: + raise ValueError("Media magic bytes do not match the declared content type") + if not photo.thumbnail_storage_key: + with Image.open(io.BytesIO(content)) as source: + thumbnail = ImageOps.exif_transpose(source).convert("RGB") + thumbnail.thumbnail((640, 640)) + output = io.BytesIO() + thumbnail.save(output, format="WEBP", quality=82, method=6) + thumbnail_content = output.getvalue() + thumbnail_key = f"organizations/{job.organization_id}/events/{job.event_id}/media/thumbnails/{photo.id}.webp" + storage.put(thumbnail_key, thumbnail_content, "image/webp") + photo.thumbnail_storage_key = thumbnail_key + photo.thumbnail_size_bytes = len(thumbnail_content) + organization = db.get(Organization, job.organization_id) + organization.storage_used_bytes += len(thumbnail_content) + db.add(StorageUsageLedger(organization_id=job.organization_id, event_id=job.event_id, photo_id=photo.id, operation="ADD", bytes=len(thumbnail_content))) results = ml_faces(content, photo.filename, content_type) enrollments = db.scalars(select(FaceEnrollment).join(Participant).where(Participant.event_id == job.event_id)).all() - for result in results: - detection = FaceDetection(organization_id=job.organization_id, event_id=job.event_id, photo_id=photo.id, box=result["box"], embedding=result["embedding"], detector_confidence=result["box"]["probability"]) + for face_index, result in enumerate(results): + box = result["box"] + detection = FaceDetection( + organization_id=job.organization_id, + event_id=job.event_id, + photo_id=photo.id, + face_index=face_index, + box=box, + landmarks=result.get("landmarks"), + face_width=max(0, int(box.get("x_max", 0) - box.get("x_min", 0))), + face_height=max(0, int(box.get("y_max", 0) - box.get("y_min", 0))), + embedding=result["embedding"], + embedding_vector=result["embedding"], + detector_confidence=box["probability"], + model_name="retinaface-r50", + model_version=settings.detector_model_version, + ) db.add(detection) db.flush() ranked = sorted(((cosine(result["embedding"], enrollment.embedding), enrollment.participant_id) for enrollment in enrollments), reverse=True) best_score, participant_id = ranked[0] if ranked else (0.0, None) runner_up = ranked[1][0] if len(ranked) > 1 else -1.0 # A conservative margin prevents lookalikes from being assigned automatically. - if best_score >= 0.85 and best_score - runner_up >= 0.08: + margin = best_score - runner_up + if best_score >= settings.match_auto_threshold and margin >= settings.match_runner_up_margin: state = "high" - elif best_score >= 0.65 and best_score - runner_up >= 0.04: + elif best_score >= settings.match_review_threshold: state = "review" else: state = "low" participant_id = None - db.add(FaceMatch(organization_id=job.organization_id, event_id=job.event_id, detection_id=detection.id, participant_id=participant_id, confidence=max(0.0, best_score), state=state)) + db.add(FaceMatch( + organization_id=job.organization_id, + event_id=job.event_id, + detection_id=detection.id, + participant_id=participant_id, + confidence=max(0.0, best_score), + second_best_score=runner_up if runner_up >= 0 else None, + margin=margin if ranked else None, + state=state, + decision_source="AUTO", + model_name="adaface-ir101-ms1mv2", + model_version=settings.embedder_model_version, + threshold_profile_version=settings.threshold_profile_version, + )) job.status = "completed" job.progress = 100 + job.progress_current = 100 job.completed_at = utcnow() + job.heartbeat_at = utcnow() photo.processing_status = "ready" + db.add(OutboxEvent( + aggregate_type="processing_job", + aggregate_id=job.id, + organization_id=job.organization_id, + event_type="fdx.v2.ml.process.completed", + event_version=1, + correlation_id=job.correlation_id or job.id, + payload={"job_id": job.id, "media_id": photo.id, "faces_detected": len(results)}, + )) db.flush() remaining = db.scalar(select(func.count(ProcessingJob.id)).where(ProcessingJob.event_id == job.event_id, ProcessingJob.status.in_(["queued", "processing"]))) or 0 if remaining == 0: @@ -90,19 +238,49 @@ def process_job(job_id: str) -> None: job = db.get(ProcessingJob, job_id) photo = db.get(Photo, job.photo_id) if job else None if job: - job.status = "failed" + retry_delays = [0, 30, 120, 600, 1800] + retryable = job.attempt < job.max_attempts + job.status = "RETRY_SCHEDULED" if retryable else "DEAD_LETTERED" job.error = str(exc) - job.completed_at = utcnow() + job.next_attempt_at = utcnow() + timedelta(seconds=retry_delays[min(job.attempt, len(retry_delays) - 1)]) if retryable else None + job.completed_at = None if retryable else utcnow() + if not retryable: + db.add(OutboxEvent( + aggregate_type="processing_job", + aggregate_id=job.id, + organization_id=job.organization_id, + event_type="fdx.v2.ml.process.dlq", + event_version=1, + correlation_id=job.correlation_id or job.id, + payload={"job_id": job.id, "media_id": job.photo_id, "error": str(exc)}, + )) if photo: - photo.processing_status = "failed" + photo.processing_status = "queued" if job and job.status == "RETRY_SCHEDULED" else "failed" audit(db, None, "Processing job failed", f"{job_id}: {exc}", "danger", job.organization_id if job else None) db.commit() def run_retention() -> None: with SessionLocal() as db: - expired_events = db.scalars(select(Event).where(Event.expires_at <= date.today(), Event.status != "expired")).all() + expired_exports = db.scalars(select(GalleryExport).where(GalleryExport.expires_at <= utcnow(), GalleryExport.storage_key.is_not(None))).all() + for item in expired_exports: + storage.delete(item.storage_key) + organization = db.get(Organization, item.organization_id) + if organization: + organization.storage_used_bytes = max(0, organization.storage_used_bytes - item.size_bytes) + db.add(StorageUsageLedger(organization_id=item.organization_id, event_id=item.event_id, operation="DELETE", bytes=-item.size_bytes)) + item.storage_key = None + item.size_bytes = 0 + item.status = "EXPIRED" + expired_organizations = db.scalars(select(Organization).where(Organization.expires_at < datetime.now(timezone.utc).date(), Organization.status == "active")).all() + for organization in expired_organizations: + organization.status = "expired" + user_ids = select(User.id).where(User.organization_id == organization.id) + db.query(RefreshSession).filter(RefreshSession.user_id.in_(user_ids), RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}, synchronize_session=False) + audit(db, None, "Organization account expired", organization.name, organization_id=organization.id) + expired_events = db.scalars(select(Event).where((Event.expires_at <= date.today()) | (Event.status.in_(["DELETION_PENDING", "deletion_pending"])), Event.status.notin_(["expired", "DELETED"]))).all() for event in expired_events: + deletion_requested = event.status.upper() == "DELETION_PENDING" photos = db.scalars(select(Photo).where(Photo.event_id == event.id)).all() released = sum(photo.size_bytes + photo.thumbnail_size_bytes for photo in photos) for photo in photos: @@ -113,14 +291,34 @@ def run_retention() -> None: for enrollment in enrollments: storage.delete(enrollment.storage_key) released += sum(enrollment.size_bytes for enrollment in enrollments) + exports = db.scalars(select(GalleryExport).where(GalleryExport.event_id == event.id, GalleryExport.storage_key.is_not(None))).all() + for export in exports: + storage.delete(export.storage_key) + released += sum(export.size_bytes for export in exports) if photos: db.execute(delete(Photo).where(Photo.id.in_([photo.id for photo in photos]))) if enrollments: db.execute(delete(FaceEnrollment).where(FaceEnrollment.id.in_([enrollment.id for enrollment in enrollments]))) + db.execute(delete(Participant).where(Participant.event_id == event.id)) organization = db.get(Organization, event.organization_id) organization.storage_used_bytes = max(0, organization.storage_used_bytes - released) - event.status = "expired" + db.add(StorageUsageLedger(organization_id=organization.id, event_id=event.id, operation="DELETE", bytes=-released)) + event.status = "DELETED" if deletion_requested else "expired" audit(db, None, "Retention cleanup completed", f"{event.name}: {len(photos)} photos removed", organization_id=event.organization_id) + db.flush() + deleting_organizations = db.scalars(select(Organization).where(Organization.status == "deletion_pending")).all() + for organization in deleting_organizations: + active_events = db.scalar(select(func.count(Event.id)).where(Event.organization_id == organization.id, Event.status.notin_(["DELETED", "expired"]))) or 0 + if active_events: + continue + # Remove event-owned imports and workflow records first so their + # created_by references cannot block removal of tenant users. + db.execute(delete(Event).where(Event.organization_id == organization.id)) + db.flush() + db.execute(delete(User).where(User.organization_id == organization.id)) + organization.status = "deleted" + organization.storage_used_bytes = 0 + audit(db, None, "Organization deletion completed", organization.name, organization_id=organization.id) db.commit() @@ -131,15 +329,18 @@ def main() -> None: last_retention = 0.0 last_email_poll = 0.0 last_queue_poll = 0.0 + last_outbox_poll = 0.0 + last_reservation_poll = 0.0 + last_notification_poll = 0.0 while True: now = time.monotonic() if consumer is None and now - last_consumer_attempt > 5: last_consumer_attempt = now try: - consumer = KafkaConsumer(settings.kafka_topic, bootstrap_servers=settings.kafka_bootstrap_servers.split(","), security_protocol=settings.kafka_security_protocol, group_id="fdx-ml-workers", auto_offset_reset="earliest", enable_auto_commit=True, value_deserializer=lambda value: __import__("json").loads(value.decode())) + consumer = KafkaConsumer(settings.kafka_topic, "fdx.v2.ml.process.requested", "fdx.v2.gallery.export.requested", bootstrap_servers=settings.kafka_bootstrap_servers.split(","), security_protocol=settings.kafka_security_protocol, group_id="fdx-workers", auto_offset_reset="earliest", enable_auto_commit=True, value_deserializer=lambda value: __import__("json").loads(value.decode())) except KafkaError: print("Waiting for Kafka; PostgreSQL fallback remains active...", flush=True) - if settings.retention_scheduler_enabled and now - last_retention > 3600: + if settings.retention_scheduler_enabled and now - last_retention > settings.retention_poll_seconds: run_retention() last_retention = now if now - last_email_poll > settings.email_poll_seconds: @@ -148,6 +349,15 @@ def main() -> None: if now - last_queue_poll > 10: process_pending_jobs() last_queue_poll = now + if now - last_outbox_poll > 2: + publish_outbox_events() + last_outbox_poll = now + if now - last_reservation_poll > 60: + expire_storage_reservations() + last_reservation_poll = now + if now - last_notification_poll > 3600: + send_scheduled_notifications() + last_notification_poll = now if consumer is None: time.sleep(1) continue @@ -155,7 +365,8 @@ def main() -> None: batches = consumer.poll(timeout_ms=5_000, max_records=10) for messages in batches.values(): for message in messages: - process_job(message.value["job_id"]) + if "job_id" in message.value: + process_job(message.value["job_id"]) except KafkaError: try: consumer.close(autocommit=False) @@ -172,13 +383,94 @@ def retry_failed_emails() -> None: db.commit() +def send_scheduled_notifications() -> None: + """Queue idempotent enrollment, expiry, and permanent-failure notices.""" + with SessionLocal() as db: + today = utcnow().date() + + def send_once(organization_id: str, recipient: str, subject: str, html: str) -> None: + existing = db.scalar(select(EmailOutbox.id).where(EmailOutbox.recipient == recipient, EmailOutbox.subject == subject)) + if existing: + return + item = queue_email(db, organization_id, recipient, subject, html) + dispatch_email(db, item) + + reminders = db.scalars(select(Participant).where( + Participant.enrollment_status.in_(["invited", "opened"]), + Participant.enrollment_expires_at > utcnow(), + Participant.created_at <= utcnow() - timedelta(hours=24), + )).all() + for participant in reminders: + send_once( + participant.organization_id, + participant.email, + f"Reminder: verify your face for {participant.event.name} [{participant.id}]", + "

Your secure FDX enrollment is still incomplete. Use the original invitation before it expires.

", + ) + + administrators = db.scalars(select(User).where(User.role == UserRole.ORG_ADMIN, User.status == "active")).all() + for administrator in administrators: + organization = administrator.organization + if organization.expires_at: + days = (organization.expires_at - today).days + if days in {7, 1}: + send_once(organization.id, administrator.email, f"FDX account expires in {days} day(s) [{organization.id}]", "

Your Organization's FDX access is approaching its configured expiry date.

") + events = db.scalars(select(Event).where(Event.organization_id == organization.id, Event.expires_at.in_([today + timedelta(days=7), today + timedelta(days=1)]), Event.status.notin_(["expired", "DELETED"]))).all() + for event in events: + days = (event.expires_at - today).days + send_once(organization.id, administrator.email, f"Event data expires in {days} day(s) [{event.id}]", f"

Media and biometric data for {event.name} will be removed by its retention policy.

") + permanent_failures = db.scalars(select(EmailOutbox).where(EmailOutbox.organization_id == organization.id, EmailOutbox.status == "failed", EmailOutbox.attempts >= settings.email_max_attempts)).all() + for failure in permanent_failures: + send_once(organization.id, administrator.email, f"FDX email delivery requires attention [{failure.id}]", f"

A message to {failure.recipient} could not be delivered after bounded retries.

") + db.commit() + + def process_pending_jobs() -> None: """Use PostgreSQL as a durable fallback when Kafka publication is interrupted.""" with SessionLocal() as db: - job_ids = db.scalars(select(ProcessingJob.id).where(ProcessingJob.status == "queued", ProcessingJob.created_at <= utcnow() - timedelta(seconds=30)).order_by(ProcessingJob.created_at).limit(10)).all() + job_ids = db.scalars(select(ProcessingJob.id).where(ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]), (ProcessingJob.next_attempt_at.is_(None)) | (ProcessingJob.next_attempt_at <= utcnow()), ProcessingJob.created_at <= utcnow() - timedelta(seconds=5)).order_by(ProcessingJob.created_at).limit(10)).all() for job_id in job_ids: process_job(job_id) +def publish_outbox_events() -> None: + with SessionLocal() as db: + rows = db.scalars(select(OutboxEvent).where(OutboxEvent.published_at.is_(None)).order_by(OutboxEvent.created_at).with_for_update(skip_locked=True).limit(50)).all() + for row in rows: + envelope = { + "event_id": row.id, + "event_type": row.event_type, + "event_version": row.event_version, + "occurred_at": row.created_at.isoformat(), + "correlation_id": row.correlation_id, + "organization_id": row.organization_id, + "actor_type": "SYSTEM", + "actor_id": None, + "payload": row.payload, + **({"job_id": row.payload.get("job_id")} if row.payload.get("job_id") else {}), + } + row.publish_attempts += 1 + if publish_event(row.event_type, envelope): + row.published_at = utcnow() + row.last_error = None + else: + row.last_error = "Kafka publication failed" + db.commit() + + +def expire_storage_reservations() -> None: + with SessionLocal() as db: + rows = db.scalars(select(StorageReservation).where(StorageReservation.status == "RESERVED", StorageReservation.expires_at <= utcnow()).with_for_update(skip_locked=True)).all() + for row in rows: + row.status = "EXPIRED" + db.add(StorageUsageLedger(organization_id=row.organization_id, event_id=row.event_id, operation="RELEASE", bytes=row.bytes)) + batch = db.get(UploadBatch, row.upload_batch_id) + if batch and batch.status not in {"COMPLETE", "CANCELLED"}: + batch.status = "CANCELLED" + for record in batch.manifest or []: + storage.delete(record["storage_key"]) + db.commit() + + if __name__ == "__main__": main() diff --git a/backend/requirements.txt b/backend/requirements.txt index b5277d4..b24b758 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,15 +1,17 @@ -fastapi==0.116.1 +fastapi==0.141.1 uvicorn[standard]==0.35.0 SQLAlchemy==2.0.41 alembic==1.16.2 psycopg[binary]==3.2.9 -PyJWT==2.10.1 +pgvector==0.5.0 +PyJWT==2.13.0 +argon2-cffi==25.1.0 redis==6.2.0 kafka-python-ng==2.2.3 boto3==1.39.11 httpx==0.28.1 -python-multipart==0.0.20 +python-multipart==0.0.32 openpyxl==3.1.5 xlrd==2.0.2 -Pillow==11.3.0 +Pillow==12.3.0 email-validator==2.2.0 diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..4c632d2 --- /dev/null +++ b/backend/tests/test_security.py @@ -0,0 +1,58 @@ +from datetime import date, timedelta + +from app.auth import hash_password, new_opaque_token, token_pair, verify_password +from app.database import SessionLocal +from app.main import app +from app.models import Event, Organization, OrganizationType, User, UserRole +from app.worker import cosine +from fastapi.testclient import TestClient +from sqlalchemy import delete + + +def test_argon2_password_round_trip(): + encoded = hash_password("Correct Horse Battery Staple") + assert encoded.startswith("$argon2id$") + assert verify_password("Correct Horse Battery Staple", encoded) + assert not verify_password("wrong password", encoded) + + +def test_opaque_tokens_store_only_hashes(): + raw, digest = new_opaque_token() + assert raw != digest + assert len(digest) == 64 + assert raw not in digest + + +def test_cosine_similarity_is_bounded_for_normalized_embeddings(): + assert cosine([1.0, 0.0], [1.0, 0.0]) == 1.0 + assert cosine([1.0, 0.0], [0.0, 1.0]) == 0.0 + assert cosine([1.0, 0.0], [-1.0, 0.0]) == -1.0 + + +def test_cross_tenant_event_endpoints_return_not_found(): + suffix = new_opaque_token()[0][:12] + with SessionLocal() as db: + tenant_a = Organization(name=f"Tenant A {suffix}", type=OrganizationType.COMPANY, contact_email=f"a-{suffix}@example.com") + tenant_b = Organization(name=f"Tenant B {suffix}", type=OrganizationType.COMPANY, contact_email=f"b-{suffix}@example.com") + db.add_all([tenant_a, tenant_b]) + db.flush() + user_a = User(organization_id=tenant_a.id, name="Admin A", email=f"admin-a-{suffix}@example.com", password_hash=hash_password("Correct Horse Battery Staple"), role=UserRole.ORG_ADMIN, status="active") + event_b = Event(organization_id=tenant_b.id, name=f"Private event {suffix}", event_date=date.today(), retention_days=30, expires_at=date.today() + timedelta(days=30), status="DRAFT") + db.add_all([user_a, event_b]) + db.commit() + token = token_pair(user_a)["access_token"] + event_id = event_b.id + tenant_a_id = tenant_a.id + + headers = {"Authorization": f"Bearer {token}"} + with TestClient(app) as client: + assert client.get(f"/api/v2/events/{event_id}", headers=headers).status_code == 404 + assert client.patch(f"/api/v2/events/{event_id}", headers=headers, json={"name": "Forbidden"}).status_code == 404 + assert client.post(f"/api/v2/events/{event_id}/upload-batches", headers=headers, json={"expected_files": 1, "reserved_bytes": 1024}).status_code == 404 + + with SessionLocal() as db: + tenant_b_id = db.get(Event, event_id).organization_id + db.execute(delete(Event).where(Event.id == event_id)) + db.execute(delete(User).where(User.organization_id.in_([tenant_a_id, tenant_b_id]))) + db.execute(delete(Organization).where(Organization.id.in_([tenant_a_id, tenant_b_id]))) + db.commit() diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf index d941de9..fa1fc23 100644 --- a/deploy/nginx/default.conf +++ b/deploy/nginx/default.conf @@ -20,19 +20,40 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /api/v2/auth/login { + limit_req zone=login_limit burst=5 nodelay; + proxy_pass http://fdx_api; + proxy_set_header Host $host; + proxy_set_header X-Request-ID $request_id; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /api/ { limit_req zone=api_limit burst=50 nodelay; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_pass http://fdx_api; proxy_set_header Host $host; + proxy_set_header X-Request-ID $request_id; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + location = /metrics { + proxy_pass http://fdx_api; + proxy_set_header Host $host; + proxy_set_header X-Request-ID $request_id; + } + location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-store"; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(self)" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'" always; } } diff --git a/docker-compose.yml b/docker-compose.yml index 41d0727..a71a94d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ name: fdx services: postgres: - image: postgres:17-alpine + image: pgvector/pgvector:0.8.6-pg17 environment: POSTGRES_DB: fdx POSTGRES_USER: fdx @@ -82,6 +82,10 @@ services: STORAGE_BACKEND: ${STORAGE_BACKEND:-local} STORAGE_ROOT: /data/storage JWT_SECRET: ${JWT_SECRET:-replace-this-before-production} + JWT_ISSUER: ${JWT_ISSUER:-fdx} + JWT_AUDIENCE: ${JWT_AUDIENCE:-fdx-web} + ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-15} + REFRESH_TOKEN_DAYS: ${REFRESH_TOKEN_DAYS:-7} FRONTEND_URL: ${FRONTEND_URL:-http://127.0.0.1:8080} FDX_ENVIRONMENT: ${FDX_ENVIRONMENT:-development} FDX_SUPER_ADMIN_EMAIL: ${FDX_SUPER_ADMIN_EMAIL:-superadmin@fdx.io} @@ -89,8 +93,16 @@ services: EMAIL_PROVIDER: ${EMAIL_PROVIDER:-outbox} EMAIL_FROM: ${EMAIL_FROM:-FDX } RESEND_API_KEY: ${RESEND_API_KEY:-} + EMAIL_WEBHOOK_SECRET: ${EMAIL_WEBHOOK_SECRET:-} AWS_REGION: ${AWS_REGION:-ap-south-1} S3_BUCKET: ${S3_BUCKET:-} + CONSENT_POLICY_VERSION: ${CONSENT_POLICY_VERSION:-2026-08-13} + MATCH_AUTO_THRESHOLD: ${MATCH_AUTO_THRESHOLD:-0.85} + MATCH_REVIEW_THRESHOLD: ${MATCH_REVIEW_THRESHOLD:-0.65} + MATCH_RUNNER_UP_MARGIN: ${MATCH_RUNNER_UP_MARGIN:-0.08} + THRESHOLD_PROFILE_VERSION: ${THRESHOLD_PROFILE_VERSION:-default-v1} + FDX_DETECTOR_MODEL_VERSION: ${FDX_DETECTOR_MODEL_VERSION:-retinaface-r50-v1} + FDX_EMBEDDER_MODEL_VERSION: ${FDX_EMBEDDER_MODEL_VERSION:-adaface-ir101-ms1mv2-v1} volumes: - object_storage:/data/storage depends_on: @@ -98,7 +110,7 @@ services: redis: { condition: service_healthy } kafka: { condition: service_healthy } healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"] interval: 10s timeout: 5s retries: 20 diff --git a/docs/spec-implementation.md b/docs/spec-implementation.md new file mode 100644 index 0000000..f927f42 --- /dev/null +++ b/docs/spec-implementation.md @@ -0,0 +1,48 @@ +# FDX V2 Specification Implementation Map + +This document maps the authoritative requirements in [`specs.md`](specs.md) to executable FDX components. It separates implemented application behavior from release activities that require production accounts, real infrastructure, or approved data. + +## Implemented application contract + +| Specification area | Implementation | +| --- | --- | +| Single login, JWT, RBAC | `/api/v2/auth/*`, Argon2id passwords, 15-minute audience-bound access JWTs, hashed rotating refresh sessions in an HttpOnly `SameSite=Strict` cookie, logout/session revocation, invitation and password-reset flows. | +| Tenant isolation | Organization identity is taken only from the authenticated user. Tenant-owned event, participant, media, job, match, gallery, delivery, usage, and log queries include that tenant. The mandatory cross-tenant GET/PATCH/presign test is automated. | +| Organizations and users | Super Admin dashboard, organization lifecycle, storage/retention/account-expiry policy, administrator invitation, activation/suspension, job visibility, and audit views. | +| Events and participants | Backend event state machine; event lifecycle endpoints; CSV/XLS/XLSX/XLSM validation; invalid/duplicate preview; explicit idempotent confirmation; participant CRUD and invitation delivery. | +| Public enrollment | High-entropy hashed single-use tokens, expiry, Redis/NGINX rate limits, explicit versioned consent record, image validation, RetinaFace/AdaFace enrollment, 512-value pgvector-compatible embedding, quota accounting, and token consumption. | +| Uploads and private media | Storage reservation, quota lock, manifest/checksum, direct presigned S3 PUT (local authenticated fallback), completion verification, duplicate hash suppression, private object keys, asynchronous WebP thumbnails, signed reads, and usage ledger. | +| Kafka/outbox/jobs | Transactional outbox, versioned correlation envelope, idempotent locked consumers, PostgreSQL fallback queue, bounded exponential retry, heartbeat/progress, dead-letter state, manual retry, and failure visibility. | +| ML and matching | RetinaFace R50 + AdaFace IR101, checksum/model registry, normalized 512-dimensional embeddings, cosine score, runner-up margin, configurable auto/review thresholds, model/threshold reproducibility fields, manual confirm/reject audit. | +| Galleries and delivery | Tenant-scoped gallery construction, expiring hashed gallery token, authorized per-photo signed download, provider-backed result email, delivery/webhook status, and worker-generated expiring private ZIP export. | +| Email | Provider adapter for Resend, SES, or persistent development outbox; invitation, enrollment, reminder, result, password reset, account/event-expiry warning, bounded retry, delivery-failure alert, and signed idempotent webhook ingestion. | +| Retention/deletion | Scheduled reservation expiry, event/account expiry, asynchronous event/organization deletion, session/link invalidation through parent lifecycle, originals/thumbnails/exports/enrollments removal, storage release, and audit trail. | +| Observability/security | Request/correlation IDs, redacted route-template JSON logs, Prometheus-format API counters, dependency probes, CSP/HSTS/content-type/referrer/permissions headers, CORS allowlist, and V2 error envelope. | +| Frontend | In-memory access token plus refresh cookie, one login and role routing, forgot/reset/invite pages, import preview/confirm, direct folder upload through V2, public V2 enrollment, private thumbnail gallery, per-photo download, and async Download All status. | +| Deployment | Dockerized web/API/worker/Gunicorn ML, NGINX routing/rate limits, pgvector PostgreSQL, local Compose, and AWS CloudFormation for ALB/ASG/EC2/RDS/ElastiCache/MSK/S3/Glacier/SES/Secrets Manager/IAM/EventBridge/Lambda. | +| CI and migrations | Forward Alembic migrations, pgvector migration service, frontend lint/build/audit, backend lint/compile/tests/dependency audit, Compose/CloudFormation/shell validation, and container builds. Production does not auto-create schema. | + +## Automated acceptance evidence + +`tools/verify_v2.mjs` runs the production-shaped workflow against the live stack and fails on any contract violation. It verifies: + +- refresh rotation, replay rejection, single-use invitations, and logout revocation; +- mandatory cross-tenant event GET, PATCH, and upload-reservation denial as `404`; +- invalid import preview and idempotent confirmation; +- consent, one-time enrollment, real 512-dimensional ML enrollment; +- checksummed direct upload and idempotent completion; +- real detection/matching, private gallery isolation, signed download, and asynchronous ZIP output. + +`tools/verify_platform.mjs` preserves regression coverage for the original dashboard API, restricted Staff permissions, Excel compatibility, secure thumbnails, email state, event details, deletion, and storage release. `backend/tests` runs unit and API tenant-isolation tests in CI. + +## Production release gates + +The code paths and infrastructure declarations are present, but the following cannot be truthfully completed inside a source checkout and must be evidenced in staging before production promotion: + +1. Configure real DNS/ACM, RDS, ElastiCache, MSK, S3, SES or Resend, provider webhook secret, monitoring destination, and production secrets. +2. Run `tools/verify_models.sh` against the deployed ONNX artifacts and perform the genuine/impostor calibration described in `specs.md`; approve the threshold profile for the actual cameras and population. +3. Run the full V2 verifier against staging with private S3, managed Kafka/Redis/PostgreSQL, and real email delivery/webhooks. +4. Execute and record an RDS point-in-time restore drill and verify S3 non-current-version expiry against the biometric retention commitment. +5. Run load/security tests against the target capacity, confirm the documented latency goals, and approve consent/privacy wording through the appropriate organizational review. + +These are release evidence requirements—not mock or alternate application implementations. Development intentionally uses the durable outbox and local private storage adapters when provider credentials are absent. diff --git a/docs/specs.md b/docs/specs.md new file mode 100644 index 0000000..d27f07b --- /dev/null +++ b/docs/specs.md @@ -0,0 +1,4642 @@ +# FDX V2 — Complete Technical Specification + +**Document type:** Developer handoff / implementation specification + +**Project:** FDX + +**Version:** V2 + +**Status:** Implementation baseline + +**Prepared from:** the agreed FDX workflow, the current FDX repository, and the `claude/frontend-super-admin-dashboard-8j5pu7` frontend branch + +**Repository:** https://github.com/Dijo-404/FDX +**Frontend branch:** `claude/frontend-super-admin-dashboard-8j5pu7` + +--- + +## 0. Purpose of this document + +This document is the complete implementation baseline for FDX V2. + +It converts the agreed product workflow into a developer-ready specification covering: + +- product scope and workflows +- single-login authentication +- Super Admin and Organization Admin behavior +- multi-tenant isolation +- organization, user, event, participant, upload, matching, gallery, and delivery lifecycles +- database schema +- API endpoints +- Kafka topics and worker jobs +- Redis responsibilities +- S3/object-storage layout +- email flows +- face enrollment +- ML inference and face matching +- data retention and expiry +- storage quotas +- auditing +- security and privacy +- observability +- Docker/runtime structure +- deployment architecture +- CI/CD +- backup and disaster recovery +- failure handling and idempotency +- testing +- migration from the current frontend +- acceptance criteria +- implementation milestones + +The intent is that a developer should be able to use this file as the primary technical reference for building FDX V2. + +> **Coverage statement:** This specification covers 100% of the requirements discussed for the current FDX V2 scope. Future features not yet discussed are outside this version of the specification. + +--- + +# 1. Product Definition + +FDX is a multi-tenant event-photo discovery and delivery platform. + +A College or Company creates an event, uploads a list of attendees and event photographs, securely enrolls each attendee's face, processes the event gallery using face detection and recognition, matches attendees with the photographs in which they appear, and privately delivers those photographs to each attendee. + +The FDX Super Admin operates the overall platform and manages organizations, organization users, storage limits, retention policies, expiry, platform health, and audit visibility. + +The product can be summarized as: + +```text +Organization + ↓ +Create Event + ↓ +Upload Participants + ↓ +Invite Participants + ↓ +Participants Enroll Face + ↓ +Upload Event Photos + ↓ +Detect Faces + ↓ +Generate Face Embeddings + ↓ +Match Participants + ↓ +Build Private Galleries + ↓ +Email Results + ↓ +Participant Views / Downloads Photos +``` + +--- + +# 2. Core Product Principles + +FDX V2 must follow these principles. + +1. **One login system** + - There are not separate authentication systems for Super Admin and College/Company users. + - A single login endpoint authenticates all platform users. + - The authenticated role determines the dashboard and permissions. + +2. **College and Company are the same backend entity** + - Backend name: `Organization` + - `organization_type = COLLEGE | COMPANY` + - Do not create separate College and Company database models. + +3. **Strict tenant isolation** + - An Organization Admin can only access resources belonging to their own organization. + - No frontend parameter may override tenant ownership. + - Tenant filtering must be enforced on the backend. + +4. **Participants do not require FDX accounts** + - Event participants access face enrollment and photo galleries through secure, expiring tokens. + +5. **Private-by-default media** + - Event photos and biometric enrollment images must never be public S3 objects. + - Access must use authenticated APIs or short-lived signed URLs. + +6. **Asynchronous processing** + - Large photo uploads and ML workloads must run as background jobs. + - API requests must not remain open for entire event-processing operations. + +7. **Auditable actions** + - Sensitive operations such as login, organization creation, user creation, retention changes, participant imports, face processing, gallery creation, email delivery, and deletion must be logged. + +8. **Biometric data minimization** + - Retain only data required for the active event and policy. + - Face enrollment images and embeddings must follow retention/deletion rules. + +--- + +# 3. Existing FDX Baseline + +The current FDX repository already contains a local face-processing implementation using: + +- RetinaFace R50 for face detection +- AdaFace IR101 for face identity embeddings/matching +- ONNX Runtime +- CUDAExecutionProvider when NVIDIA GPU is available +- CPU fallback +- 512-dimensional normalized embeddings +- conservative cosine-similarity matching logic +- support for low-resolution and difficult face crops +- browser-side/current-build matching safeguards + +The new frontend branch already contains: + +- React +- Vite +- React Router +- `/login` +- protected Super Admin routes under `/admin` +- protected College routes under `/college` +- Super Admin dashboard pages +- College dashboard pages + +FDX V2 should preserve the current ML work while replacing the local-only data flow with a proper multi-tenant web platform architecture. + +--- + +# 4. Terminology + +| Term | Meaning | +|---|---| +| FDX | The overall platform | +| Super Admin | FDX platform administrator | +| Organization | A College or Company using FDX | +| Organization Admin | User who manages one Organization | +| Participant | Event attendee whose photos are being discovered | +| Event | An Organization-managed event | +| Enrollment | Secure participant face-capture process | +| Event Media | Photos uploaded for an event | +| Face Detection | Face coordinates/landmarks discovered in an image | +| Face Embedding | Numerical identity representation generated by AdaFace | +| Match | Relationship between a participant and a detected face/photo | +| Gallery | Private participant-specific collection of matched photos | +| Delivery | Sending the participant access to their gallery | +| Retention Policy | Rules controlling how long data is stored | +| Storage Quota | Maximum storage allocated to an Organization | +| Tenant | An Organization and its logically isolated resources | + +--- + +# 5. Actors and Roles + +## 5.1 Super Admin + +The Super Admin manages the FDX platform. + +Responsibilities: + +- log in through the common login page +- create Organizations +- choose Organization type: College or Company +- create Organization Admin users +- suspend or reactivate Organizations +- view total platform usage +- configure storage quotas +- configure retention periods +- configure account expiry +- view Organization usage +- view system jobs +- view platform audit logs +- inspect failed jobs +- inspect failed email deliveries +- monitor platform health +- initiate administrative cleanup when required + +The Super Admin does not need to manage normal event operations. + +--- + +## 5.2 Organization Admin + +The Organization Admin manages events belonging to exactly one Organization. + +Responsibilities: + +- log in through the same login page +- view Organization dashboard +- create/update/archive events +- upload participant lists +- validate participant imports +- send or resend enrollment invitations +- upload event photo folders/batches +- start event processing +- monitor processing progress +- view face-match results +- review uncertain matches if manual review is enabled +- create participant galleries +- send result emails +- view Organization logs +- manage event data according to permissions + +--- + +## 5.3 Participant + +A Participant is not a normal authenticated FDX user. + +Participant capabilities: + +- receive enrollment email +- open secure enrollment link +- read consent/privacy notice +- allow camera access +- capture or upload a face image as permitted +- submit face enrollment +- receive result email +- open private gallery +- view matched photos +- download individual photos +- download all matched photos if enabled +- request deletion where supported by policy + +--- + +# 6. Role Model + +Initial V2 roles: + +```text +super_admin +org_admin +``` + +Future-compatible role: + +```text +org_staff +``` + +The initial release does not require `org_staff`, but database and RBAC design should permit adding it later. + +Current frontend role name `college` should be migrated to `org_admin`. + +--- + +# 7. Single Login Workflow + +```mermaid +flowchart TD + A[FDX Login] --> B[Email + Password] + B --> C[POST /api/v2/auth/login] + C --> D{Valid credentials?} + D -- No --> E[401 Invalid credentials] + D -- Yes --> F[Issue Access Token + Refresh Session] + F --> G{Role} + G -- super_admin --> H[/admin] + G -- org_admin --> I[/organization] +``` + +Requirements: + +1. One login form. +2. Backend validates credentials. +3. Backend returns user role and organization context. +4. Frontend routes according to role. +5. Frontend route protection is UX only. +6. Backend performs final authorization on every protected endpoint. + +--- + +# 8. Authentication Design + +## 8.1 Recommended authentication model + +Use: + +- email + password +- Argon2id password hashing +- short-lived JWT access token +- rotating refresh token +- refresh token stored in an HttpOnly, Secure, SameSite cookie +- access token held in memory by the SPA +- explicit logout +- session revocation support +- password reset +- invitation-based account activation + +Recommended defaults: + +| Setting | Default | +|---|---| +| Access token lifetime | 15 minutes | +| Refresh token lifetime | 7 days | +| Invitation token lifetime | 72 hours | +| Password reset token lifetime | 30 minutes | +| Enrollment link lifetime | Event configurable; default 7 days | +| Gallery link lifetime | Configurable; default 7 days | + +These values must be environment/configuration driven. + +--- + +## 8.2 JWT claims + +Example: + +```json +{ + "sub": "user_uuid", + "role": "org_admin", + "organization_id": "org_uuid", + "session_id": "session_uuid", + "jti": "token_uuid", + "iat": 1786550000, + "exp": 1786550900, + "iss": "fdx", + "aud": "fdx-web" +} +``` + +For `super_admin`: + +```json +{ + "sub": "user_uuid", + "role": "super_admin", + "organization_id": null +} +``` + +Never trust an `organization_id` supplied by the frontend when the authenticated user's organization is already known. + +--- + +# 9. RBAC Matrix + +| Capability | Super Admin | Organization Admin | +|---|---:|---:| +| Login | Yes | Yes | +| View global dashboard | Yes | No | +| Create Organization | Yes | No | +| Update Organization | Yes | No | +| Suspend Organization | Yes | No | +| Delete/close Organization | Yes | No | +| Set storage quota | Yes | No | +| Set retention policy | Yes | No | +| Set account expiry | Yes | No | +| Create Organization Admin | Yes | Optional later | +| View global logs | Yes | No | +| View Organization logs | Yes | Own organization | +| View own Organization dashboard | Optional | Yes | +| Create event | No | Yes | +| Edit event | No | Yes | +| Archive event | Administrative override | Yes | +| Upload participants | No | Yes | +| Send enrollment invitations | No | Yes | +| Upload event photos | No | Yes | +| Start/retry event processing | Administrative override | Yes | +| View matches | Administrative override | Yes | +| Review uncertain matches | Administrative override | Yes | +| Send participant result email | No | Yes | +| Delete event data | Administrative override | Yes, policy controlled | + +--- + +# 10. Multi-Tenant Isolation + +Every tenant-owned row must include `organization_id`. + +Examples: + +- events +- participants +- media +- face enrollments +- detections +- matches +- galleries +- deliveries +- jobs +- logs where applicable + +Backend rule: + +```text +Authenticated Organization Admin + ↓ +JWT organization_id + ↓ +Every tenant-owned query is scoped to organization_id +``` + +Incorrect: + +```sql +SELECT * FROM events WHERE id = :event_id; +``` + +Required: + +```sql +SELECT * +FROM events +WHERE id = :event_id + AND organization_id = :authenticated_org_id; +``` + +Recommended defense in depth: + +- application-layer tenant dependency/filter +- PostgreSQL Row Level Security where practical +- S3 object keys containing organization UUID +- Kafka payloads containing organization UUID +- audit logs containing organization UUID +- Redis keys namespaced by organization where tenant-specific + +Cross-tenant object references must return `404`, not information revealing that the object exists. + +--- + +# 11. Organization Model + +An Organization represents either a College or Company. + +Required fields: + +```text +id +name +organization_type +primary_email +status +storage_limit_bytes +storage_used_bytes +default_retention_days +account_expires_at +created_at +updated_at +``` + +Organization types: + +```text +COLLEGE +COMPANY +``` + +Organization statuses: + +```text +ACTIVE +SUSPENDED +EXPIRED +DELETION_PENDING +DELETED +``` + +--- + +# 12. Organization Creation Workflow + +```mermaid +flowchart TD + A[Super Admin] --> B[Organizations] + B --> C[Create Organization] + C --> D[Name] + D --> E[Type: College or Company] + E --> F[Primary Contact] + F --> G[Storage Quota] + G --> H[Retention Policy] + H --> I[Optional Account Expiry] + I --> J[Create] + J --> K[Organization ACTIVE] + K --> L[Create Organization Admin] +``` + +Validation: + +- Organization name required. +- Organization type required. +- Primary contact email valid. +- Storage quota > 0. +- Retention days > 0. +- Expiry may be null. +- Duplicate Organization names are allowed only if business rules permit; use UUID as canonical identity. + +--- + +# 13. Organization Admin Invitation Workflow + +```text +Super Admin + ↓ +Create Organization Admin + ↓ +Name + Email + Organization + ↓ +Create inactive user + ↓ +Generate single-use invitation token + ↓ +Store token hash + ↓ +Send invitation email + ↓ +User opens link + ↓ +Sets password + ↓ +Account becomes ACTIVE +``` + +Invitation token rules: + +- store only token hash in database +- single use +- expires +- invalidated after password setup +- can be revoked +- new invitation invalidates previous active invitation if configured + +--- + +# 14. Super Admin Dashboard + +Required metrics: + +- total Organizations +- active Organizations +- suspended Organizations +- total Organization Admin users +- total events +- active events +- total uploaded photos +- total storage used +- storage usage by Organization +- processing jobs queued +- processing jobs running +- failed jobs +- emails sent +- failed emails +- expiring event data +- Organization accounts approaching expiry +- system health summary + +Optional but recommended: + +- 24-hour job volume +- 7-day media upload volume +- ML worker utilization +- API error rate +- email bounce rate +- top storage consumers + +--- + +# 15. Storage and Expiry Management + +Super Admin configures Organization-level defaults: + +```text +storage_limit_bytes +default_retention_days +account_expires_at +archive_policy +``` + +Events can inherit or use a stricter policy. + +An Organization Admin must not be able to extend retention beyond the maximum policy allowed by the Super Admin unless explicitly permitted. + +--- + +## 15.1 Expiry workflow + +```mermaid +flowchart TD + A[Scheduled Retention Worker] --> B[Find expired resources] + B --> C{Expired?} + C -- No --> D[Keep] + C -- Yes --> E[Mark deletion pending] + E --> F[Delete private galleries] + F --> G[Delete face match data] + G --> H[Delete face detections/embeddings] + H --> I[Delete enrollment image] + I --> J[Delete event media or archive if policy allows] + J --> K[Update storage ledger] + K --> L[Write audit log] + L --> M[Mark deletion complete] +``` + +Important: + +- expired data must not remain accessible merely because S3 deletion is delayed +- application state should deny access as soon as deletion begins +- cleanup operations must be idempotent +- failures must be retryable + +--- + +# 16. Organization Dashboard + +Recommended navigation: + +```text +Dashboard +Events +Participants +Uploads +Processing +Face Matches +Deliveries +Logs +Settings +``` + +The word `Students` must be replaced with `Participants` so the UI supports both Colleges and Companies. + +--- + +# 17. Event Lifecycle + +Recommended event states: + +```text +DRAFT +ENROLLMENT_OPEN +READY_FOR_UPLOAD +UPLOADING +PROCESSING +REVIEW +READY_TO_DELIVER +DELIVERING +DELIVERED +ARCHIVED +EXPIRED +DELETION_PENDING +DELETED +``` + +State transitions should be controlled by backend business logic rather than arbitrary frontend updates. + +Example: + +```mermaid +stateDiagram-v2 + [*] --> DRAFT + DRAFT --> ENROLLMENT_OPEN + ENROLLMENT_OPEN --> READY_FOR_UPLOAD + READY_FOR_UPLOAD --> UPLOADING + UPLOADING --> PROCESSING + PROCESSING --> REVIEW + PROCESSING --> READY_TO_DELIVER + REVIEW --> READY_TO_DELIVER + READY_TO_DELIVER --> DELIVERING + DELIVERING --> DELIVERED + DELIVERED --> ARCHIVED + ARCHIVED --> EXPIRED + EXPIRED --> DELETION_PENDING + DELETION_PENDING --> DELETED +``` + +--- + +# 18. Event Creation + +Required event fields: + +```text +id +organization_id +name +description +location +starts_at +ends_at +status +retention_days +enrollment_opens_at +enrollment_closes_at +gallery_expires_at +created_by +created_at +updated_at +``` + +Minimum UI: + +- Event name +- Description +- Date/time +- Location +- Retention period +- Enrollment deadline + +--- + +# 19. Participant Import + +Organization Admin uploads a CSV or Excel file. + +Required fields: + +```text +name +email +``` + +Optional future fields: + +```text +external_id +phone +department +team +registration_number +metadata +``` + +Example: + +```csv +name,email +Dijo,dijo@example.com +John,john@example.com +Alex,alex@example.com +``` + +--- + +## 19.1 Import workflow + +```text +Upload CSV/XLSX + ↓ +Create participant_import record + ↓ +Parse file + ↓ +Validate required columns + ↓ +Normalize emails + ↓ +Detect duplicate rows + ↓ +Detect duplicate participants in same event + ↓ +Preview validation result + ↓ +Organization Admin confirms + ↓ +Create participants + ↓ +Queue enrollment emails +``` + +Do not silently discard invalid rows. + +Return: + +- total rows +- valid rows +- duplicate rows +- invalid rows +- detailed row-level errors + +--- + +# 20. Participant Model + +Participant state: + +```text +PENDING_INVITE +INVITED +INVITE_FAILED +ENROLLMENT_OPENED +ENROLLED +ENROLLMENT_REJECTED +PROCESSING +MATCHED +NO_MATCH +DELIVERY_PENDING +DELIVERED +DELIVERY_FAILED +EXPIRED +DELETED +``` + +A participant is event-scoped. + +The same email attending multiple events should create separate participant records unless a future cross-event identity feature is explicitly implemented. + +This prevents accidental biometric linkage across unrelated events. + +--- + +# 21. Face Enrollment Email + +When participants are confirmed: + +```text +Participant + ↓ +Create enrollment token + ↓ +Generate secure URL + ↓ +Queue email + ↓ +Email worker sends message +``` + +Example concept: + +```text +Photos from are being processed. + +To find photographs containing you, verify your face using the secure link. + +[Find My Photos] +``` + +Do not expose internal participant IDs in URLs. + +Recommended route: + +```text +https://app.example.com/enroll/ +``` + +--- + +# 22. Public Enrollment Workflow + +Participants do not create an account. + +```mermaid +flowchart TD + A[Enrollment Email] --> B[Open Secure Link] + B --> C[Validate Token] + C --> D[Show Event + Organization] + D --> E[Privacy / Consent] + E --> F[Camera Permission] + F --> G[Take Selfie] + G --> H[Preview] + H --> I{Accept?} + I -- No --> G + I -- Yes --> J[Upload] + J --> K[Face Quality Validation] + K --> L{Usable?} + L -- No --> M[Ask for Retake] + M --> G + L -- Yes --> N[Generate Embedding] + N --> O[Mark Participant Enrolled] +``` + +--- + +# 23. Enrollment Security Requirements + +Enrollment token: + +- opaque random value +- at least 128 bits entropy +- store hash only +- scoped to exactly one participant and one event +- expiry time +- revocation time +- consumed status +- rate limited +- no predictable identifiers + +The enrollment page must display: + +- Organization name +- Event name +- why the face is being collected +- how it is used +- retention/deletion statement +- consent control + +Do not proceed until required consent is captured. + +--- + +# 24. Face Enrollment Data + +Recommended stored fields: + +```text +face_enrollments +---------------- +id +organization_id +event_id +participant_id +status +source_media_id +embedding +embedding_model +embedding_version +embedding_dimension +quality_score +detector_confidence +consent_id +created_at +expires_at +deleted_at +``` + +For AdaFace: + +```text +embedding_dimension = 512 +embedding_model = adaface-ir101-ms1mv2 +metric = cosine +normalization = L2 +``` + +Recommended PostgreSQL implementation: + +- enable `pgvector` +- store embedding as `vector(512)` + +For event-scale matching, workers may additionally build a temporary in-memory NumPy/FAISS index, but PostgreSQL remains the durable source of truth. + +--- + +# 25. Event Media Upload + +Organization Admin can upload: + +- folder selection through browser +- many individual files +- optional ZIP upload + +For large events, the browser must upload directly to S3 using presigned multipart URLs instead of proxying all image bytes through FastAPI. + +--- + +## 25.1 Upload workflow + +```mermaid +flowchart TD + A[Organization Admin] --> B[Event] + B --> C[Create Upload Batch] + C --> D[Request Presigned URLs] + D --> E[Browser uploads directly to S3] + E --> F[Complete Upload] + F --> G[Backend verifies object] + G --> H[Create media record] + H --> I[Publish processing job] +``` + +--- + +# 26. Media Validation + +Accepted formats for V2: + +```text +JPEG +PNG +WEBP +``` + +Optional future: + +```text +HEIC +video +RAW formats +``` + +Validation: + +- MIME type +- extension +- magic bytes +- maximum file size +- decodable image +- pixel dimensions +- checksum +- duplicate detection within event + +Recommended checksum: + +```text +SHA-256 +``` + +Do not rely only on filename extensions. + +--- + +# 27. S3 / Object Storage Architecture + +Use private object storage. + +Recommended bucket strategy: + +```text +fdx--private +``` + +Example prefixes: + +```text +organizations/ + / + events/ + / + imports/ + /participants.csv + + enrollment/ + / + /source.jpg + + media/ + original/ + .jpg + + thumbnails/ + .webp + + galleries/ + / + manifest.json + + exports/ + .zip +``` + +Do not use participant names or emails in object keys. + +--- + +## 27.1 S3 requirements + +- Block Public Access enabled. +- Encryption at rest. +- Prefer SSE-KMS in production. +- HTTPS only. +- Presigned PUT for uploads. +- Presigned GET for downloads. +- Short signed URL lifetimes. +- Content-Type restrictions. +- Optional Content-MD5/checksum validation. +- Versioning policy decided per environment. +- Lifecycle rules controlled by FDX retention logic. +- Access logs/CloudTrail data events where required. + +--- + +## 27.2 Glacier / archival policy + +The original architecture included Glacier/cold storage. + +FDX V2 rule: + +- Glacier is optional. +- Archive only when the Organization's policy explicitly permits it. +- A deletion policy must delete, not archive, biometric/event data. +- Cold archival must never be used to bypass an expiry/deletion requirement. + +Possible lifecycle: + +```text +Original photo + ↓ +Active S3 + ↓ after configured age +S3 Glacier storage class + ↓ at final retention deadline +Permanent deletion +``` + +--- + +# 28. Storage Quota Enforcement + +Before accepting a new upload: + +```text +current_usage + reserved_upload_bytes <= storage_limit +``` + +Use a reservation model for multipart/batch uploads. + +Tables: + +```text +storage_usage_ledger +storage_reservations +``` + +States: + +```text +RESERVED +COMMITTED +RELEASED +EXPIRED +``` + +This prevents concurrent uploads from exceeding the quota. + +Return HTTP `413` or domain-specific `409` when quota is exceeded. + +--- + +# 29. ML Processing Pipeline + +Core pipeline: + +```text +Photo + ↓ +Decode + ↓ +RetinaFace R50 + ↓ +Face boxes + landmarks + ↓ +Quality / size checks + ↓ +Alignment + ↓ +AdaFace IR101 + ↓ +512-d L2-normalized embeddings + ↓ +Compare with enrolled participant embeddings + ↓ +Candidate scores + ↓ +Confidence policy + ↓ +Accepted / Review / Unknown +``` + +--- + +# 30. Current ML Baseline to Preserve + +Current FDX has conservative matching behavior. + +The production service should preserve the current algorithmic safety philosophy: + +- RetinaFace R50 detection +- AdaFace IR101 embedding +- cosine similarity +- normalized embeddings +- unknown by default when confidence is insufficient +- stricter handling for low-resolution faces +- runner-up margin checks +- avoidance of weak identity expansion when competing identities exist +- model/version-aware cache invalidation +- explicit model version recording + +Current local thresholds may be used as an initial engineering baseline, but they must be configurable and calibrated using genuine and impostor pairs from the actual deployment camera/event data before production acceptance. + +--- + +# 31. Suggested Matching Policy + +FDX V2 should use three output classes: + +```text +AUTO_MATCH +REVIEW_REQUIRED +UNKNOWN +``` + +Example configurable policy: + +```text +auto_match_threshold +review_threshold +runner_up_margin +low_resolution_threshold +minimum_face_size +minimum_detector_confidence +``` + +Decision example: + +```text +score >= auto_match_threshold +AND score - second_best_score >= runner_up_margin + => AUTO_MATCH + +score >= review_threshold + => REVIEW_REQUIRED + +otherwise + => UNKNOWN +``` + +Never expose a raw similarity score to participants as a probability. + +--- + +# 32. Match Storage + +Store candidate/result information for reproducibility. + +```text +face_matches +------------ +id +organization_id +event_id +participant_id +face_detection_id +media_id +similarity_score +second_best_score +margin +decision +decision_source +model_name +model_version +threshold_profile_version +created_at +reviewed_by +reviewed_at +``` + +`decision_source`: + +```text +AUTO +MANUAL_CONFIRM +MANUAL_REJECT +REPROCESS +``` + +--- + +# 33. Manual Review + +For medium-confidence matches: + +Organization Admin can view: + +- event photo +- detected face crop +- participant enrollment reference +- similarity score +- competing candidate score +- model/version +- confirm +- reject + +Requirements: + +- manual review action is audit logged +- reviewer identity stored +- rejected match cannot be automatically recreated without a new processing version unless explicitly reset +- no cross-Organization participant comparisons + +--- + +# 34. Processing Jobs + +Use a durable database row for every significant background job. + +```text +processing_jobs +--------------- +id +organization_id +event_id +job_type +resource_type +resource_id +status +attempt +max_attempts +progress_current +progress_total +error_code +error_message +queued_at +started_at +finished_at +heartbeat_at +``` + +Statuses: + +```text +QUEUED +RUNNING +SUCCEEDED +FAILED +RETRY_SCHEDULED +CANCEL_REQUESTED +CANCELLED +DEAD_LETTERED +``` + +--- + +# 35. Kafka Architecture + +Kafka is used for durable asynchronous event processing. + +Recommended topic prefix: + +```text +fdx.v2. +``` + +Required topics: + +| Topic | Purpose | Key | +|---|---|---| +| `fdx.v2.media.ready` | New verified event media is ready | `media_id` | +| `fdx.v2.ml.process.requested` | Request detection + embedding | `media_id` | +| `fdx.v2.ml.process.completed` | Detection/embedding finished | `media_id` | +| `fdx.v2.match.requested` | Request matching for detected faces | `event_id` | +| `fdx.v2.match.completed` | Matching operation finished | `event_id` | +| `fdx.v2.gallery.build.requested` | Build participant galleries | `participant_id` | +| `fdx.v2.gallery.build.completed` | Gallery ready | `participant_id` | +| `fdx.v2.email.send.requested` | Send transactional email | `email_message_id` | +| `fdx.v2.email.status` | Provider/delivery updates | `email_message_id` | +| `fdx.v2.retention.cleanup.requested` | Cleanup expired resources | `resource_id` | +| `fdx.v2.audit.event` | Optional audit event stream | `organization_id` | + +Each retryable processing topic should have a DLQ strategy. + +Examples: + +```text +fdx.v2.ml.process.dlq +fdx.v2.email.send.dlq +fdx.v2.retention.cleanup.dlq +``` + +--- + +# 36. Kafka Message Envelope + +All events should use a common envelope. + +```json +{ + "event_id": "uuid", + "event_type": "fdx.v2.ml.process.requested", + "event_version": 1, + "occurred_at": "2026-08-13T00:00:00Z", + "correlation_id": "uuid", + "organization_id": "uuid", + "actor_type": "USER", + "actor_id": "uuid", + "payload": {} +} +``` + +Required: + +- unique `event_id` +- versioned schema +- timestamp +- correlation ID +- tenant ID when tenant-owned +- idempotent consumers + +Do not place images or embeddings directly into Kafka messages. + +Send object/database references instead. + +--- + +# 37. Kafka Delivery Semantics + +Assume at-least-once delivery. + +Therefore every consumer must be idempotent. + +Example: + +```text +Receive ml.process.requested(media_id) + ↓ +Check processing result already exists for model version + ↓ +Yes → acknowledge without reprocessing +No → process +``` + +Use the database transaction + outbox pattern for important state changes that must publish Kafka events reliably. + +--- + +# 38. Transactional Outbox + +Recommended table: + +```text +outbox_events +------------- +id +aggregate_type +aggregate_id +organization_id +event_type +event_version +payload_json +created_at +published_at +publish_attempts +``` + +Flow: + +1. API updates business state. +2. API inserts outbox event in same database transaction. +3. Outbox publisher sends event to Kafka. +4. Publisher marks row published. +5. Duplicate publishes remain safe because consumers are idempotent. + +--- + +# 39. Redis Responsibilities + +Redis is not the source of truth. + +PostgreSQL remains durable storage. + +Redis responsibilities: + +### 39.1 Rate limiting + +Examples: + +```text +rate:login: +rate:enroll: +rate:api: +rate:email: +``` + +### 39.2 Distributed locks + +```text +lock:event::processing +lock:media::ml +lock:participant::gallery +lock:retention: +``` + +### 39.3 Cache + +```text +cache:org::policy +cache:user::permissions +cache:event::summary +``` + +### 39.4 Processing progress + +```text +progress:event: +``` + +Use Redis for fast progress display; periodically persist important progress to PostgreSQL. + +### 39.5 Token/session revocation + +```text +revoked:jti: +``` + +Can be used for access-token emergency revocation. + +### 39.6 Idempotency + +Short-lived endpoint idempotency records: + +```text +idem:: +``` + +Never store the only copy of a critical business record in Redis. + +--- + +# 40. Email Architecture + +Use a provider abstraction: + +```text +EmailService + ├── Resend adapter + └── AWS SES adapter +``` + +Choose one provider as primary per environment. + +Do not require both providers to be active. + +Recommended: + +- Resend for simpler initial transactional delivery, or +- AWS SES for AWS-native production deployment + +The application must not depend directly on provider-specific code outside the email adapter. + +--- + +# 41. Required Email Types + +## 41.1 Organization Admin invitation + +Purpose: + +- activate FDX account +- set initial password + +## 41.2 Participant enrollment invitation + +Purpose: + +- explain event +- provide secure enrollment link + +## 41.3 Enrollment reminder + +Optional configured reminders to participants who have not enrolled. + +## 41.4 Photos ready + +Purpose: + +- notify participant that matches are available +- link to private gallery + +## 41.5 Delivery retry/failure operational alert + +Visible to Organization Admin or Super Admin when delivery repeatedly fails. + +## 41.6 Password reset + +Standard authenticated-user password recovery. + +## 41.7 Organization expiry warning + +Sent before account expiry. + +## 41.8 Event-data expiry warning + +Optional notification before scheduled deletion. + +--- + +# 42. Email Message Model + +```text +email_messages +-------------- +id +organization_id +event_id +participant_id +user_id +template +recipient_email +provider +provider_message_id +status +attempt_count +last_error +queued_at +sent_at +delivered_at +failed_at +``` + +Statuses: + +```text +QUEUED +SENDING +SENT +DELIVERED +BOUNCED +FAILED +SUPPRESSED +``` + +--- + +# 43. Email Idempotency + +The same logical email must not be accidentally sent repeatedly. + +Example unique logical key: + +```text +participant_id + template + template_version + event_id + delivery_generation +``` + +Manual "Resend" creates a new delivery generation. + +--- + +# 44. Private Participant Gallery + +When matches are ready: + +```text +Participant + ↓ +Matched photos + ↓ +Create/update gallery manifest + ↓ +Generate access token + ↓ +Send photos-ready email +``` + +The gallery should contain only photos matched to that participant. + +A single event photo may be present in multiple participant galleries. + +Do not physically duplicate original images for every gallery unless needed for exports. + +Store gallery relationships instead. + +--- + +# 45. Gallery Access + +Recommended route: + +```text +https://app.example.com/gallery/ +``` + +Token: + +- random +- hash stored server side +- scoped to participant + event +- expires +- revocable +- rate limited + +Gallery API returns signed image URLs only for media the participant is authorized to view. + +--- + +# 46. Gallery Features + +Required: + +- event name +- Organization name +- thumbnail grid +- full image view +- download single image +- optional download selected +- optional download all +- expiry message + +Recommended: + +- no participant names embedded in URLs +- no public S3 links +- prevent directory indexing +- signed URL expiry: approximately 5–15 minutes +- gallery token may last longer than individual S3 URLs + +--- + +# 47. Database Technology + +Recommended: + +```text +PostgreSQL 15+ +SQLAlchemy 2.x +Alembic +Pydantic 2.x +pgvector +``` + +The exact deployed versions must be pinned in dependency files. + +Use UUID primary keys. + +Use UTC timestamps. + +Use `TIMESTAMPTZ`. + +Use soft-delete fields only where recovery/audit requirements justify them; expired biometric/media data must still be physically deleted according to policy. + +--- + +# 48. Core Database Schema + +## 48.1 `organizations` + +```text +id UUID PK +name VARCHAR NOT NULL +organization_type ENUM(COLLEGE, COMPANY) NOT NULL +primary_email CITEXT NOT NULL +status ENUM NOT NULL +storage_limit_bytes BIGINT NOT NULL +storage_used_bytes BIGINT NOT NULL DEFAULT 0 +default_retention_days INTEGER NOT NULL +account_expires_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +deleted_at TIMESTAMPTZ NULL +``` + +Indexes: + +```text +(status) +(account_expires_at) +(lower(name)) +``` + +--- + +## 48.2 `users` + +```text +id UUID PK +organization_id UUID NULL FK organizations(id) +email CITEXT UNIQUE NOT NULL +name VARCHAR NOT NULL +password_hash TEXT NULL +role ENUM(super_admin, org_admin) NOT NULL +status ENUM(INVITED, ACTIVE, SUSPENDED, DISABLED) NOT NULL +last_login_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +``` + +Constraints: + +- `super_admin` must have `organization_id = NULL` +- `org_admin` must have `organization_id IS NOT NULL` + +--- + +## 48.3 `user_invitations` + +```text +id UUID PK +user_id UUID FK users(id) +token_hash TEXT UNIQUE NOT NULL +expires_at TIMESTAMPTZ NOT NULL +accepted_at TIMESTAMPTZ NULL +revoked_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +``` + +--- + +## 48.4 `refresh_sessions` + +```text +id UUID PK +user_id UUID FK users(id) +refresh_token_hash TEXT NOT NULL +user_agent TEXT NULL +ip_address INET NULL +expires_at TIMESTAMPTZ NOT NULL +revoked_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +last_used_at TIMESTAMPTZ NULL +``` + +--- + +## 48.5 `events` + +```text +id UUID PK +organization_id UUID FK organizations(id) NOT NULL +name VARCHAR NOT NULL +description TEXT NULL +location VARCHAR NULL +starts_at TIMESTAMPTZ NULL +ends_at TIMESTAMPTZ NULL +status ENUM NOT NULL +retention_days INTEGER NOT NULL +enrollment_opens_at TIMESTAMPTZ NULL +enrollment_closes_at TIMESTAMPTZ NULL +gallery_expires_at TIMESTAMPTZ NULL +expires_at TIMESTAMPTZ NOT NULL +created_by UUID FK users(id) +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +deleted_at TIMESTAMPTZ NULL +``` + +Indexes: + +```text +(organization_id, status) +(organization_id, created_at DESC) +(expires_at) +``` + +--- + +## 48.6 `participant_imports` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +source_object_key TEXT NOT NULL +status ENUM(UPLOADED, VALIDATING, READY, CONFIRMED, FAILED) +total_rows INTEGER DEFAULT 0 +valid_rows INTEGER DEFAULT 0 +invalid_rows INTEGER DEFAULT 0 +duplicate_rows INTEGER DEFAULT 0 +validation_report JSONB NULL +created_by UUID NOT NULL +created_at TIMESTAMPTZ NOT NULL +confirmed_at TIMESTAMPTZ NULL +``` + +--- + +## 48.7 `participants` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +name VARCHAR NOT NULL +email CITEXT NOT NULL +external_id VARCHAR NULL +status ENUM NOT NULL +enrollment_status ENUM NOT NULL +delivery_status ENUM NOT NULL +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +deleted_at TIMESTAMPTZ NULL +``` + +Recommended unique index: + +```text +UNIQUE(event_id, lower(email)) +``` + +--- + +## 48.8 `participant_enrollment_tokens` + +```text +id UUID PK +participant_id UUID NOT NULL +token_hash TEXT UNIQUE NOT NULL +expires_at TIMESTAMPTZ NOT NULL +opened_at TIMESTAMPTZ NULL +consumed_at TIMESTAMPTZ NULL +revoked_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +``` + +--- + +## 48.9 `consents` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +participant_id UUID NOT NULL +consent_type VARCHAR NOT NULL +policy_version VARCHAR NOT NULL +accepted BOOLEAN NOT NULL +accepted_at TIMESTAMPTZ NOT NULL +ip_address INET NULL +user_agent TEXT NULL +``` + +--- + +## 48.10 `upload_batches` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +status ENUM(CREATED, UPLOADING, VERIFYING, COMPLETE, FAILED, CANCELLED) +expected_files INTEGER NULL +uploaded_files INTEGER DEFAULT 0 +reserved_bytes BIGINT DEFAULT 0 +committed_bytes BIGINT DEFAULT 0 +created_by UUID NOT NULL +created_at TIMESTAMPTZ NOT NULL +completed_at TIMESTAMPTZ NULL +``` + +--- + +## 48.11 `media_assets` + +Used for event photos and enrollment images. + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +participant_id UUID NULL +upload_batch_id UUID NULL +media_type ENUM(EVENT_PHOTO, ENROLLMENT_IMAGE, THUMBNAIL, EXPORT) +storage_key TEXT UNIQUE NOT NULL +original_filename TEXT NULL +mime_type VARCHAR NOT NULL +size_bytes BIGINT NOT NULL +width INTEGER NULL +height INTEGER NULL +sha256 CHAR(64) NOT NULL +status ENUM(UPLOADED, VERIFIED, PROCESSING, READY, FAILED, DELETED) +created_at TIMESTAMPTZ NOT NULL +deleted_at TIMESTAMPTZ NULL +``` + +Indexes: + +```text +(organization_id, event_id) +(event_id, sha256) +(status) +``` + +--- + +## 48.12 `face_enrollments` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +participant_id UUID NOT NULL +source_media_id UUID NOT NULL +status ENUM(PENDING, VALID, REJECTED, EXPIRED, DELETED) +embedding vector(512) NULL +model_name VARCHAR NOT NULL +model_version VARCHAR NOT NULL +quality_score REAL NULL +detector_confidence REAL NULL +created_at TIMESTAMPTZ NOT NULL +expires_at TIMESTAMPTZ NOT NULL +deleted_at TIMESTAMPTZ NULL +``` + +--- + +## 48.13 `face_detections` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +media_id UUID NOT NULL +face_index INTEGER NOT NULL +bbox JSONB NOT NULL +landmarks JSONB NULL +detector_confidence REAL NOT NULL +face_width INTEGER NULL +face_height INTEGER NULL +quality_class ENUM(GOOD, LOW_RESOLUTION, REJECTED) +embedding vector(512) NULL +model_name VARCHAR NOT NULL +model_version VARCHAR NOT NULL +created_at TIMESTAMPTZ NOT NULL +``` + +Unique: + +```text +UNIQUE(media_id, face_index, model_name, model_version) +``` + +--- + +## 48.14 `face_matches` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +participant_id UUID NOT NULL +face_detection_id UUID NOT NULL +media_id UUID NOT NULL +similarity_score REAL NOT NULL +second_best_score REAL NULL +margin REAL NULL +decision ENUM(AUTO_MATCH, REVIEW_REQUIRED, UNKNOWN, CONFIRMED, REJECTED) +decision_source ENUM(AUTO, MANUAL_CONFIRM, MANUAL_REJECT, REPROCESS) +threshold_profile_version VARCHAR NOT NULL +model_name VARCHAR NOT NULL +model_version VARCHAR NOT NULL +reviewed_by UUID NULL +reviewed_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +``` + +--- + +## 48.15 `galleries` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +participant_id UUID NOT NULL +status ENUM(BUILDING, READY, DELIVERED, EXPIRED, REVOKED, DELETED) +access_token_hash TEXT UNIQUE NULL +access_expires_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +``` + +Unique: + +```text +UNIQUE(event_id, participant_id) +``` + +--- + +## 48.16 `gallery_items` + +```text +id UUID PK +gallery_id UUID NOT NULL +media_id UUID NOT NULL +match_id UUID NOT NULL +created_at TIMESTAMPTZ NOT NULL +``` + +Unique: + +```text +UNIQUE(gallery_id, media_id) +``` + +--- + +## 48.17 `email_messages` + +As defined in the email section. + +--- + +## 48.18 `deliveries` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NOT NULL +participant_id UUID NOT NULL +gallery_id UUID NULL +delivery_type ENUM(ENROLLMENT_INVITE, RESULT_GALLERY) +status ENUM(PENDING, SENT, DELIVERED, FAILED) +email_message_id UUID NULL +attempt INTEGER NOT NULL DEFAULT 0 +created_at TIMESTAMPTZ NOT NULL +delivered_at TIMESTAMPTZ NULL +``` + +--- + +## 48.19 `processing_jobs` + +As defined in the job section. + +--- + +## 48.20 `storage_usage_ledger` + +```text +id UUID PK +organization_id UUID NOT NULL +event_id UUID NULL +media_id UUID NULL +operation ENUM(ADD, DELETE, RESERVE, RELEASE, ARCHIVE) +bytes BIGINT NOT NULL +created_at TIMESTAMPTZ NOT NULL +``` + +--- + +## 48.21 `audit_logs` + +```text +id UUID PK +organization_id UUID NULL +actor_type ENUM(USER, PARTICIPANT, SYSTEM, WORKER) +actor_id UUID NULL +action VARCHAR NOT NULL +resource_type VARCHAR NULL +resource_id UUID NULL +request_id UUID NULL +ip_address INET NULL +user_agent TEXT NULL +metadata JSONB NULL +created_at TIMESTAMPTZ NOT NULL +``` + +Audit logs should be append-only to application users. + +--- + +## 48.22 `outbox_events` + +As defined earlier. + +--- + +# 49. API Conventions + +Base path: + +```text +/api/v2 +``` + +Content type: + +```text +application/json +``` + +Except: + +- CSV/XLSX import +- image uploads where direct upload is necessary +- webhook provider payloads + +Use UUIDs in API payloads. + +All timestamps: + +```text +ISO 8601 UTC +``` + +Example: + +```text +2026-08-12T18:30:00Z +``` + +--- + +# 50. Standard API Response + +Success: + +```json +{ + "data": {}, + "meta": { + "request_id": "uuid" + } +} +``` + +List: + +```json +{ + "data": [], + "meta": { + "request_id": "uuid", + "page": 1, + "page_size": 50, + "total": 250 + } +} +``` + +Error: + +```json +{ + "error": { + "code": "EVENT_NOT_FOUND", + "message": "Event was not found.", + "details": {} + }, + "meta": { + "request_id": "uuid" + } +} +``` + +--- + +# 51. HTTP Status Policy + +| Status | Use | +|---|---| +| 200 | Successful read/update | +| 201 | Created | +| 202 | Async job accepted | +| 204 | Successful no-content operation | +| 400 | Malformed request | +| 401 | Authentication required/invalid | +| 403 | Authenticated but forbidden | +| 404 | Resource not found or not visible in tenant | +| 409 | State conflict/idempotency conflict | +| 413 | Upload/quota too large | +| 422 | Validation error | +| 429 | Rate limited | +| 500 | Unexpected server error | +| 503 | Dependency temporarily unavailable | + +--- + +# 52. Authentication API + +## `POST /api/v2/auth/login` + +Request: + +```json +{ + "email": "admin@example.com", + "password": "secret" +} +``` + +Response: + +```json +{ + "data": { + "access_token": "", + "expires_in": 900, + "user": { + "id": "uuid", + "name": "Name", + "email": "admin@example.com", + "role": "org_admin", + "organization_id": "uuid" + }, + "redirect_to": "/organization" + } +} +``` + +Refresh token is set as secure HttpOnly cookie. + +--- + +## `POST /api/v2/auth/refresh` + +Rotates refresh token and returns new access token. + +--- + +## `POST /api/v2/auth/logout` + +Revokes current refresh session. + +--- + +## `GET /api/v2/auth/me` + +Returns current authenticated user and tenant context. + +--- + +## `POST /api/v2/auth/forgot-password` + +Queues password reset email. + +Always use non-enumerating response. + +--- + +## `POST /api/v2/auth/reset-password` + +Consumes reset token and sets new password. + +--- + +## `POST /api/v2/auth/invitations/{token}/accept` + +Sets initial password and activates invited Organization Admin. + +--- + +# 53. Super Admin API + +## Dashboard + +```text +GET /api/v2/admin/dashboard +GET /api/v2/admin/system-health +GET /api/v2/admin/jobs +GET /api/v2/admin/jobs/{job_id} +POST /api/v2/admin/jobs/{job_id}/retry +GET /api/v2/admin/logs +``` + +--- + +## Organizations + +```text +GET /api/v2/admin/organizations +POST /api/v2/admin/organizations +GET /api/v2/admin/organizations/{organization_id} +PATCH /api/v2/admin/organizations/{organization_id} +POST /api/v2/admin/organizations/{organization_id}/suspend +POST /api/v2/admin/organizations/{organization_id}/activate +POST /api/v2/admin/organizations/{organization_id}/schedule-deletion +``` + +--- + +## Organization policy + +```text +GET /api/v2/admin/organizations/{organization_id}/storage +PUT /api/v2/admin/organizations/{organization_id}/storage + +GET /api/v2/admin/organizations/{organization_id}/retention +PUT /api/v2/admin/organizations/{organization_id}/retention +``` + +--- + +## Organization users + +```text +GET /api/v2/admin/organizations/{organization_id}/users +POST /api/v2/admin/organizations/{organization_id}/users +GET /api/v2/admin/users/{user_id} +PATCH /api/v2/admin/users/{user_id} +POST /api/v2/admin/users/{user_id}/suspend +POST /api/v2/admin/users/{user_id}/activate +POST /api/v2/admin/users/{user_id}/resend-invite +``` + +--- + +# 54. Organization API + +```text +GET /api/v2/organization +GET /api/v2/organization/dashboard +GET /api/v2/organization/usage +GET /api/v2/organization/logs +``` + +All derive organization from authenticated user. + +Do not accept an organization ID for normal Organization Admin operations unless the backend still verifies ownership. + +--- + +# 55. Event API + +```text +GET /api/v2/events +POST /api/v2/events +GET /api/v2/events/{event_id} +PATCH /api/v2/events/{event_id} +POST /api/v2/events/{event_id}/open-enrollment +POST /api/v2/events/{event_id}/close-enrollment +POST /api/v2/events/{event_id}/start-processing +POST /api/v2/events/{event_id}/cancel-processing +POST /api/v2/events/{event_id}/archive +DELETE /api/v2/events/{event_id} +``` + +`DELETE` should normally schedule controlled deletion rather than synchronously deleting thousands of objects. + +--- + +# 56. Participant Import API + +```text +POST /api/v2/events/{event_id}/participant-imports +GET /api/v2/events/{event_id}/participant-imports +GET /api/v2/events/{event_id}/participant-imports/{import_id} +POST /api/v2/events/{event_id}/participant-imports/{import_id}/confirm +``` + +For large import file: + +1. request upload URL +2. upload directly to S3 +3. confirm object +4. queue validation + +--- + +# 57. Participants API + +```text +GET /api/v2/events/{event_id}/participants +POST /api/v2/events/{event_id}/participants +GET /api/v2/events/{event_id}/participants/{participant_id} +PATCH /api/v2/events/{event_id}/participants/{participant_id} +DELETE /api/v2/events/{event_id}/participants/{participant_id} + +POST /api/v2/events/{event_id}/participants/{participant_id}/send-invite +POST /api/v2/events/{event_id}/participants/{participant_id}/resend-invite +POST /api/v2/events/{event_id}/participants/send-invites +``` + +Bulk invitation endpoint should accept filters, not thousands of IDs if avoidable. + +--- + +# 58. Public Enrollment API + +Unauthenticated but token-protected: + +```text +GET /api/v2/public/enrollment/{token} +POST /api/v2/public/enrollment/{token}/consent +POST /api/v2/public/enrollment/{token}/upload-url +POST /api/v2/public/enrollment/{token}/complete +``` + +`GET` returns only safe event/Organization information. + +Never return participant list or internal Organization data. + +--- + +# 59. Event Media API + +```text +POST /api/v2/events/{event_id}/upload-batches +GET /api/v2/events/{event_id}/upload-batches +GET /api/v2/events/{event_id}/upload-batches/{batch_id} + +POST /api/v2/events/{event_id}/upload-batches/{batch_id}/presign +POST /api/v2/events/{event_id}/upload-batches/{batch_id}/complete +POST /api/v2/events/{event_id}/upload-batches/{batch_id}/cancel + +GET /api/v2/events/{event_id}/media +GET /api/v2/events/{event_id}/media/{media_id} +DELETE /api/v2/events/{event_id}/media/{media_id} +POST /api/v2/events/{event_id}/media/{media_id}/reprocess +``` + +--- + +# 60. Processing API + +```text +GET /api/v2/events/{event_id}/processing +GET /api/v2/events/{event_id}/processing/jobs +GET /api/v2/events/{event_id}/processing/jobs/{job_id} +POST /api/v2/events/{event_id}/processing/jobs/{job_id}/retry +``` + +Response should include: + +```text +photos_total +photos_processed +photos_failed +faces_detected +matches_auto +matches_review +matches_unknown +progress_percent +estimated_remaining optional +``` + +Do not promise an ETA unless based on measured throughput. + +--- + +# 61. Face Match API + +```text +GET /api/v2/events/{event_id}/matches +GET /api/v2/events/{event_id}/matches/{match_id} +POST /api/v2/events/{event_id}/matches/{match_id}/confirm +POST /api/v2/events/{event_id}/matches/{match_id}/reject +``` + +Filters: + +```text +participant_id +media_id +decision +minimum_score +review_required +``` + +--- + +# 62. Delivery / Gallery API + +```text +POST /api/v2/events/{event_id}/galleries/build +GET /api/v2/events/{event_id}/galleries +GET /api/v2/events/{event_id}/galleries/{gallery_id} + +POST /api/v2/events/{event_id}/deliveries/send +GET /api/v2/events/{event_id}/deliveries +POST /api/v2/events/{event_id}/participants/{participant_id}/resend-results +``` + +Public: + +```text +GET /api/v2/public/gallery/{token} +POST /api/v2/public/gallery/{token}/download-url +POST /api/v2/public/gallery/{token}/download-all +``` + +--- + +# 63. Email Provider Webhooks + +Example: + +```text +POST /api/v2/webhooks/email/resend +POST /api/v2/webhooks/email/ses +``` + +Only enable the active provider endpoint. + +Webhook requirements: + +- verify provider signature +- idempotent processing +- store provider event ID +- update email status +- audit repeated failures +- do not trust unverified payloads + +--- + +# 64. Backend Service Structure + +Recommended initial architecture: modular monolith + separate workers. + +This is preferred over immediately splitting every module into microservices. + +```text +FastAPI API + ├── auth + ├── organizations + ├── users + ├── events + ├── participants + ├── uploads + ├── galleries + ├── deliveries + ├── retention + ├── audit + └── admin + +Workers + ├── ML worker + ├── Email worker + ├── Retention worker + ├── Thumbnail/export worker + └── Outbox publisher +``` + +The ML worker can be deployed on GPU machines independently of the API. + +--- + +# 65. Suggested Backend Repository Layout + +```text +backend/ +├── app/ +│ ├── main.py +│ ├── api/ +│ │ └── v2/ +│ │ ├── auth.py +│ │ ├── admin.py +│ │ ├── organizations.py +│ │ ├── events.py +│ │ ├── participants.py +│ │ ├── uploads.py +│ │ ├── matches.py +│ │ ├── galleries.py +│ │ ├── deliveries.py +│ │ ├── public.py +│ │ └── webhooks.py +│ ├── core/ +│ │ ├── config.py +│ │ ├── security.py +│ │ ├── logging.py +│ │ ├── tenant.py +│ │ └── errors.py +│ ├── db/ +│ │ ├── base.py +│ │ ├── session.py +│ │ └── models/ +│ ├── schemas/ +│ ├── services/ +│ ├── repositories/ +│ ├── kafka/ +│ ├── redis/ +│ ├── storage/ +│ ├── email/ +│ └── audit/ +├── workers/ +│ ├── ml/ +│ ├── email/ +│ ├── retention/ +│ └── outbox/ +├── migrations/ +├── tests/ +├── pyproject.toml +└── Dockerfile +``` + +--- + +# 66. ML Worker Structure + +Recommended: + +```text +workers/ml/ +├── worker.py +├── detector.py +├── embedder.py +├── align.py +├── quality.py +├── matcher.py +├── thresholds.py +├── model_registry.py +├── schemas.py +└── tests/ +``` + +The current local inference implementation should be refactored into these reusable components rather than rewritten from scratch without reason. + +--- + +# 67. Model Registry + +Record model metadata centrally. + +Example: + +```text +model_registry +-------------- +detector_name +detector_version +detector_sha256 +embedder_name +embedder_version +embedder_sha256 +embedding_dimension +metric +threshold_profile_version +activated_at +``` + +Every detection/match result must be reproducible back to a model/version. + +A model upgrade must not compare incompatible embedding spaces. + +--- + +# 68. Caching and Model Upgrade Rules + +When a model/version changes: + +- do not treat old cached detection results as equivalent unless explicitly compatible +- do not compare ArcFace and AdaFace embeddings +- invalidate affected caches +- mark old processing version +- reprocess event if required +- keep audit/history required for traceability until retention expiry + +--- + +# 69. Frontend Technical Direction + +Current branch uses React + Vite + React Router. + +V2 should retain the existing frontend as the UI baseline. + +Recommended route migration: + +```text +/login + +/admin +/admin/organizations +/admin/organizations/:id +/admin/users +/admin/storage +/admin/jobs +/admin/logs + +/organization +/organization/events +/organization/events/:eventId +/organization/events/:eventId/participants +/organization/events/:eventId/uploads +/organization/events/:eventId/processing +/organization/events/:eventId/matches +/organization/events/:eventId/deliveries +/organization/logs +/organization/settings + +/enroll/:token +/gallery/:token +``` + +Current `/college` may temporarily redirect to `/organization`. + +--- + +# 70. Frontend State + +Recommended responsibilities: + +- auth provider +- access token in memory +- current user +- role +- Organization summary +- global API client +- route guards +- request error handling +- upload manager +- processing progress polling or SSE/WebSocket + +For initial V2, polling every 2–5 seconds for active processing is acceptable. + +SSE/WebSocket may be added later. + +--- + +# 71. NGINX / Reverse Proxy + +NGINX responsibilities: + +- TLS termination if not handled upstream +- route frontend/API +- request body limits for small API uploads +- rate limiting where appropriate +- security headers +- compression +- proxy timeout configuration +- request ID propagation + +Large event media should bypass NGINX/FastAPI data transfer through direct S3 upload. + +--- + +# 72. Security Headers + +Recommended: + +```text +Strict-Transport-Security +Content-Security-Policy +X-Content-Type-Options: nosniff +Referrer-Policy +Permissions-Policy +``` + +Camera permission should be permitted only for the enrollment page/origin. + +Use secure cookie attributes in production. + +--- + +# 73. Rate Limiting + +Minimum policies: + +- login attempts by IP + account +- password reset +- invitation acceptance +- enrollment token access +- gallery token access +- presign generation +- email resend +- expensive search/filter endpoints + +Return `429`. + +Security-sensitive thresholds must be configurable. + +--- + +# 74. Audit Requirements + +Audit at minimum: + +- login success +- login failure summary without password +- logout +- password reset +- Organization create/update/suspend/activate +- user invite/activate/suspend +- storage limit changes +- retention changes +- event create/update/archive/delete +- participant import +- invitation batch +- enrollment completion +- photo upload batch +- processing started +- processing retry +- manual match confirm/reject +- gallery generated +- result delivery +- data deletion +- admin overrides + +Never log: + +- plaintext passwords +- raw JWTs +- full enrollment tokens +- signed S3 URLs +- face embeddings in general logs + +--- + +# 75. Privacy and Biometric Data + +Face images and embeddings are sensitive biometric data. + +Engineering requirements: + +- explicit participant consent before enrollment +- purpose limitation +- event-scoped identity by default +- encryption in transit +- encryption at rest +- private storage +- strict access control +- short signed media URLs +- configurable retention +- deletion workflow +- access auditing +- no cross-event recognition unless a future feature is explicitly designed and consented +- no global face database + +Legal/privacy text itself must be reviewed for the deployment jurisdiction before launch. + +--- + +# 76. Data Retention Hierarchy + +Effective retention: + +```text +minimum( + Organization maximum allowed retention, + Event configured retention, + Participant/gallery policy where stricter +) +``` + +The system should calculate a concrete `expires_at` when possible rather than recalculating policy dynamically forever. + +--- + +# 77. Scheduled Jobs + +Required scheduled tasks: + +### Every few minutes + +- detect stuck jobs +- expire stale storage reservations + +### Hourly + +- process retry queue if not fully event-driven +- expire access tokens/links where cleanup needed + +### Daily + +- find events/resources approaching expiry +- queue retention cleanup +- account expiry evaluation +- optional expiry warning email + +### Periodic + +- reconcile S3 usage with storage ledger +- health checks +- cleanup orphaned multipart uploads + +--- + +# 78. Failure Handling + +## Upload failure + +- incomplete upload remains resumable when possible +- reservation expires and storage is released +- partial objects cleaned up + +## ML failure + +- job enters retry +- bounded retry attempts +- after max attempts -> DLQ/FAILED +- event remains inspectable +- failed photo does not block all successfully processed photos unless policy chooses strict mode + +## Email failure + +- retry transient errors +- do not endlessly retry permanent bounce +- surface failure in dashboard + +## S3 failure + +- retry with backoff +- never mark media READY before object verification + +## Kafka outage + +- API commits outbox +- outbox publisher retries when Kafka returns + +## Redis outage + +- API should degrade where possible +- durable data remains in PostgreSQL +- fail closed for critical distributed-lock scenarios if duplicate processing is unsafe + +--- + +# 79. Retry Policy + +Recommended bounded exponential backoff: + +```text +attempt 1: immediate +attempt 2: +30 sec +attempt 3: +2 min +attempt 4: +10 min +attempt 5: +30 min +``` + +Exact policy can vary by job type. + +Permanent validation errors should not be retried. + +--- + +# 80. Idempotency + +Required for: + +- upload completion +- event start processing +- bulk invitations +- gallery build +- delivery send +- manual job retry +- webhook processing + +Support: + +```text +Idempotency-Key: +``` + +Persist critical idempotency results in PostgreSQL or use Redis with sufficiently durable fallback depending on endpoint importance. + +--- + +# 81. Observability + +Required pillars: + +- structured logs +- metrics +- traces/request correlation +- health checks + +Every request gets: + +```text +request_id +correlation_id +``` + +Background jobs carry correlation IDs from the originating action. + +--- + +# 82. Metrics + +API: + +- request count +- latency +- 4xx +- 5xx +- auth failures +- rate limits + +Uploads: + +- files uploaded +- bytes uploaded +- failed uploads +- storage usage + +ML: + +- queue depth +- images processed/sec +- inference latency +- faces/image +- detection failures +- auto/review/unknown counts +- GPU utilization externally + +Email: + +- queued +- sent +- delivered +- bounced +- failed + +Kafka: + +- consumer lag +- retry count +- DLQ count + +Redis: + +- memory +- connection errors +- key eviction + +Database: + +- connection pool +- query latency +- storage +- deadlocks + +--- + +# 83. Health Endpoints + +```text +GET /health/live +GET /health/ready +GET /health/dependencies +``` + +`live`: + +- process alive + +`ready`: + +- app ready to serve + +`dependencies`: + +- PostgreSQL +- Redis +- Kafka +- S3 +- active email provider +- ML worker/model readiness where relevant + +Do not expose credentials or sensitive infrastructure details. + +--- + +# 84. Deployment Architecture + +Recommended production architecture: + +```mermaid +flowchart TD + U[Browser] --> CDN[CDN / TLS / WAF optional] + CDN --> FE[React Frontend] + U --> N[NGINX / API Gateway] + N --> API[FastAPI API] + + API --> PG[(PostgreSQL)] + API --> R[(Redis)] + API --> K[Kafka] + API --> S3[(S3 Private Storage)] + + K --> ML[GPU ML Worker] + K --> EW[Email Worker] + K --> RW[Retention Worker] + K --> GW[Gallery/Export Worker] + + ML --> S3 + ML --> PG + EW --> EMAIL[Resend or AWS SES] + RW --> S3 + RW --> PG + GW --> S3 + GW --> PG +``` + +--- + +# 85. Docker + +Although the current portable FDX ML build is Docker-free, V2 production should support Dockerized application services. + +Images: + +```text +fdx-frontend +fdx-api +fdx-worker-ml +fdx-worker-email +fdx-worker-retention +fdx-worker-outbox +``` + +The GPU ML image must support NVIDIA Container Toolkit when deployed on GPU hosts. + +For local ML development, the existing Docker-free ONNX Runtime workflow can remain supported. + +--- + +# 86. AWS Deployment Mapping + +The original design referenced EC2, S3, Lambda, and Glacier. + +Recommended production mapping: + +| Need | AWS mapping | +|---|---| +| API / workers | EC2, ECS, or EKS | +| GPU ML worker | GPU EC2/ECS capacity | +| PostgreSQL | RDS PostgreSQL | +| Redis | ElastiCache Redis | +| Kafka | Amazon MSK or managed external Kafka | +| Private media | S3 | +| Cold archive | S3 Glacier classes | +| Email | SES or Resend | +| Scheduled lightweight tasks | EventBridge + worker/Lambda | +| Secrets | Secrets Manager / SSM Parameter Store | +| Logs/metrics | CloudWatch or external observability stack | + +Lambda is suitable for lightweight tasks but should not be assumed for heavy RetinaFace/AdaFace GPU inference. + +--- + +# 87. Suggested Initial Deployment + +For first production-capable deployment: + +```text +Frontend: + static React build + +API: + 2 FastAPI instances + +PostgreSQL: + managed PostgreSQL + +Redis: + managed Redis + +Kafka: + managed Kafka + +Object storage: + AWS S3 + +ML: + 1 GPU worker initially, autoscale later + +Email: + Resend OR AWS SES + +Reverse proxy: + NGINX or managed load balancer +``` + +Start simple, while keeping workers independently scalable. + +--- + +# 88. Environment Separation + +At minimum: + +```text +development +staging +production +``` + +Never share: + +- production database +- production Redis +- production S3 prefixes/bucket +- production email credentials +- production JWT signing keys +- production Kafka topics + +between environments. + +--- + +# 89. Configuration / Environment Variables + +Example categories: + +```text +APP_ENV +APP_BASE_URL +API_BASE_URL + +DATABASE_URL +REDIS_URL +KAFKA_BOOTSTRAP_SERVERS + +JWT_PRIVATE_KEY +JWT_PUBLIC_KEY +JWT_ISSUER +JWT_AUDIENCE +ACCESS_TOKEN_TTL_SECONDS +REFRESH_TOKEN_TTL_SECONDS + +S3_BUCKET +S3_REGION +S3_KMS_KEY_ID + +EMAIL_PROVIDER +RESEND_API_KEY +AWS_SES_REGION +EMAIL_FROM + +FDX_DEVICE +FDX_DETECTOR_MODEL_PATH +FDX_EMBEDDER_MODEL_PATH +FDX_DETECTOR_MODEL_VERSION +FDX_EMBEDDER_MODEL_VERSION + +MATCH_AUTO_THRESHOLD +MATCH_REVIEW_THRESHOLD +MATCH_RUNNER_UP_MARGIN + +DEFAULT_RETENTION_DAYS +DEFAULT_GALLERY_TTL_SECONDS + +LOG_LEVEL +SENTRY_DSN optional +``` + +Secrets must not be committed to Git. + +--- + +# 90. Secrets Management + +Production secrets: + +- database credentials +- Redis credentials +- Kafka credentials +- JWT signing key +- email API key +- AWS keys if instance roles cannot be used +- KMS settings +- webhook secrets + +Prefer workload IAM roles over static AWS keys. + +Rotate secrets without requiring code changes. + +--- + +# 91. Backup and Disaster Recovery + +PostgreSQL: + +- automated backups +- point-in-time recovery +- tested restore procedure + +S3: + +- protected against accidental public exposure +- versioning optional depending deletion/privacy design +- lifecycle aligned with retention + +Important tension: + +If S3 versioning preserves deleted biometric/media objects, lifecycle rules must ensure noncurrent versions are also permanently deleted according to retention commitments. + +Kafka: + +- not the authoritative store for completed business state +- retention sized for processing/replay requirements + +Redis: + +- rebuildable +- persistence optional depending use +- no critical sole-source state + +--- + +# 92. Recovery Objectives + +Initial recommended targets: + +```text +RPO: <= 24 hours for early production, improve as needed +RTO: <= 4 hours for early production, improve as needed +``` + +For a commercial deployment, define formal targets based on SLA. + +--- + +# 93. Performance Targets + +Initial engineering targets: + +API: + +```text +p95 ordinary API latency < 500 ms +excluding large upload and background ML work +``` + +Login: + +```text +p95 < 1 second under expected load +``` + +Photo upload: + +- direct-to-S3 +- parallel upload with bounded concurrency +- multipart for large objects + +Event processing: + +- asynchronous +- progress visible +- scalable horizontally by adding ML workers + +Gallery: + +- first page metadata < 1 second under normal load +- thumbnails delivered through signed object/CDN strategy + +These are engineering targets, not contractual SLA. + +--- + +# 94. Scalability + +Scale independently: + +```text +API replicas +Email workers +ML workers +Kafka partitions +Redis +PostgreSQL +``` + +Primary scale driver is likely event media + ML inference rather than basic API traffic. + +Partition Kafka appropriately. + +Do not run one unbounded event job that blocks all others. + +--- + +# 95. Processing Fairness + +To prevent one large Organization from consuming all ML capacity: + +- queue by event/media +- bounded per-Organization concurrency +- optional priority field +- Super Admin override + +Example: + +```text +max 2 active ML jobs per Organization +``` + +Configurable. + +--- + +# 96. Testing Strategy + +## 96.1 Unit tests + +- auth token functions +- RBAC +- tenant filters +- Organization policy calculations +- retention calculations +- upload validation +- matching decision logic +- email idempotency +- storage accounting + +## 96.2 API integration tests + +- login +- role routing data +- Organization CRUD +- cross-tenant access rejection +- event CRUD +- participant import +- upload batch +- processing start +- gallery access +- expiry + +## 96.3 Worker tests + +- Kafka duplicate message +- ML failure/retry +- email retry +- retention idempotency +- outbox recovery + +## 96.4 ML regression tests + +Preserve and expand current checks: + +- model checksum validation +- detector loads +- AdaFace returns normalized 512-value embeddings +- CUDA path when required +- CPU fallback when allowed +- low-light/cropped cases +- genuine/impostor evaluation set +- no comparison of incompatible embeddings +- threshold configuration regression + +## 96.5 Security tests + +- cross-tenant IDOR +- expired JWT +- revoked refresh token +- expired invite +- reused invite +- enrollment-token brute-force protection +- gallery-token isolation +- S3 object access without signature +- webhook signature failure +- upload content-type spoofing +- rate limits + +--- + +# 97. Required Tenant Isolation Test + +This test is mandatory. + +```text +Organization A admin authenticates +Organization B event ID is known +Organization A calls GET /events/ +Expected: 404 + +Organization A calls PATCH /events/ +Expected: 404 + +Organization A requests presign for B event +Expected: 404 + +Organization A requests B participant +Expected: 404 +``` + +Repeat for all major tenant-owned resources. + +--- + +# 98. CI/CD + +Recommended pipeline: + +```text +Pull Request + ↓ +Lint frontend + ↓ +Frontend tests + ↓ +Backend lint/type checks + ↓ +Backend unit tests + ↓ +API integration tests + ↓ +Migration validation + ↓ +Security/dependency scan + ↓ +Build images + ↓ +ML regression gate + ↓ +Deploy staging + ↓ +Smoke tests + ↓ +Manual/controlled production promotion +``` + +Production deployment must not occur if the ML production regression gate fails. + +--- + +# 99. Database Migrations + +Use Alembic. + +Rules: + +- migration files reviewed +- migration tested on production-like snapshot +- backward-compatible migrations preferred +- destructive migrations require explicit plan +- long-running migrations monitored +- application code must not auto-create schema in production + +--- + +# 100. Logging + +Use structured JSON logs. + +Example: + +```json +{ + "timestamp": "2026-08-13T00:00:00Z", + "level": "INFO", + "service": "fdx-api", + "request_id": "uuid", + "organization_id": "uuid", + "user_id": "uuid", + "action": "event.processing.started", + "event_id": "uuid" +} +``` + +Sensitive values must be redacted. + +--- + +# 101. Frontend Migration from Current Branch + +Current branch concepts: + +```text +/admin +/college +Students +College Admin +role="college" +``` + +V2 migration: + +```text +/admin +/organization +Participants +Organization Admin +role="org_admin" +``` + +Recommended compatibility: + +```text +/college/* → redirect to /organization/* +``` + +during transition. + +--- + +# 102. Frontend Pages Required for V2 + +## Super Admin + +```text +Login +Dashboard +Organizations +Organization Detail +Organization Users +Storage / Retention +Jobs / Failures +Logs +``` + +## Organization Admin + +```text +Dashboard +Events +Event Detail +Participants +Participant Import +Uploads +Processing +Face Matches +Deliveries +Logs +Settings +``` + +## Public + +```text +Enrollment +Enrollment Completed +Gallery +Gallery Expired +Invalid Link +``` + +--- + +# 103. Super Admin Organization Detail + +Must show: + +- Organization name/type +- status +- primary contact +- admins +- created date +- account expiry +- storage used/limit +- retention policy +- events +- processing failures +- email failures +- recent audit activity +- suspend/activate actions +- retention/storage edit actions + +--- + +# 104. Organization Event Detail + +Recommended tabs: + +```text +Overview +Participants +Uploads +Processing +Matches +Deliveries +Settings +Logs +``` + +Overview metrics: + +```text +Participants +Invited +Enrolled +Photos uploaded +Faces detected +Auto matches +Review required +Unknown +Galleries ready +Emails delivered +``` + +--- + +# 105. Processing Dashboard + +Required: + +```text +Event state +Photos total +Photos queued +Photos processing +Photos completed +Photos failed +Faces detected +Participants enrolled +Auto matches +Review required +Unknown +Current worker/job health +``` + +Actions: + +```text +Start Processing +Pause/Cancel if supported +Retry Failed +Reprocess Selected +``` + +--- + +# 106. Confidence Review UI + +For each review item: + +```text +Participant +Enrollment face +Event face crop +Full photo +Top score +Second-best score +Margin +Model version +Confirm +Reject +``` + +Do not label similarity score as "accuracy". + +--- + +# 107. Notifications in UI + +UI notification categories: + +```text +success +warning +error +info +``` + +Important examples: + +- quota almost full +- event retention approaching expiry +- participant import contains invalid rows +- upload failed +- ML worker unavailable +- processing completed with failures +- email delivery failures +- galleries ready + +--- + +# 108. Data Deletion + +Delete operations must identify all related records/objects. + +Event deletion should cover: + +- participant records as policy requires +- enrollment tokens +- enrollment images +- face embeddings +- event photos +- thumbnails +- face detections +- matches +- galleries +- exports +- delivery access tokens +- processing artifacts +- storage ledger updates + +Audit records may be retained longer only if legally/policy permitted and must not contain deleted biometric data. + +--- + +# 109. Organization Deletion + +Organization deletion should be asynchronous. + +```text +Super Admin schedules deletion + ↓ +Organization immediately disabled + ↓ +Revoke user sessions + ↓ +Revoke participant/gallery links + ↓ +Queue all event deletion + ↓ +Delete storage + ↓ +Delete tenant operational data + ↓ +Preserve minimal permitted audit record + ↓ +Mark Organization DELETED +``` + +Require stronger confirmation in UI. + +--- + +# 110. Event Expiry Warning + +Recommended configurable behavior: + +```text +T-7 days → warning to Organization Admin +T-1 day → final warning +T → disable participant access and queue cleanup +``` + +Warnings are optional but recommended. + +--- + +# 111. Storage Usage Calculation + +`storage_used_bytes` should be a cached aggregate. + +Source of truth: + +- storage usage ledger +- periodic S3 reconciliation + +Do not `LIST` the entire bucket for every dashboard request. + +--- + +# 112. Search and Pagination + +All potentially large lists need pagination. + +Examples: + +- Organizations +- users +- events +- participants +- media +- matches +- jobs +- logs +- deliveries + +Recommended: + +```text +page +page_size +sort +search +filters +``` + +Maximum page size should be bounded. + +--- + +# 113. API Filtering Examples + +Participants: + +```text +?status=ENROLLED +?email=... +?search=dijo +``` + +Matches: + +```text +?decision=REVIEW_REQUIRED +?participant_id=uuid +``` + +Jobs: + +```text +?status=FAILED +?job_type=ML_PROCESS +``` + +Events: + +```text +?status=PROCESSING +?from=... +?to=... +``` + +--- + +# 114. Concurrency Control + +For updates likely to conflict: + +- use `updated_at`/version field +- optimistic concurrency where useful +- distributed locks for processing transitions + +Do not allow two workers to process the same media version concurrently. + +--- + +# 115. Duplicate Photo Handling + +Within an event: + +```text +sha256 same +``` + +should be detected. + +Options: + +- reject duplicate +- reference same object +- allow duplicate only with explicit override + +Default recommendation: identify duplicate and skip physical duplicate upload while preserving user-visible import result. + +--- + +# 116. Thumbnail Strategy + +Generate thumbnails asynchronously after upload or during ML processing. + +Store: + +```text +media/thumbnails/.webp +``` + +Gallery should load thumbnails first. + +Full originals retrieved only when opened/downloaded. + +--- + +# 117. Export / Download All + +If participant has many photos: + +- do not synchronously ZIP in API request +- create export job +- worker creates ZIP in private S3 +- return status +- issue signed URL when ready +- delete export after short TTL + +--- + +# 118. Data Model Relationship Summary + +```mermaid +erDiagram + ORGANIZATION ||--o{ USER : has + ORGANIZATION ||--o{ EVENT : owns + EVENT ||--o{ PARTICIPANT : contains + PARTICIPANT ||--o{ FACE_ENROLLMENT : enrolls + EVENT ||--o{ MEDIA_ASSET : has + MEDIA_ASSET ||--o{ FACE_DETECTION : contains + PARTICIPANT ||--o{ FACE_MATCH : matched + FACE_DETECTION ||--o{ FACE_MATCH : candidate + PARTICIPANT ||--|| GALLERY : receives + GALLERY ||--o{ GALLERY_ITEM : contains + MEDIA_ASSET ||--o{ GALLERY_ITEM : referenced + PARTICIPANT ||--o{ DELIVERY : receives + EVENT ||--o{ PROCESSING_JOB : processes +``` + +--- + +# 119. End-to-End System Workflow + +```mermaid +flowchart TD + SA[Super Admin] --> O[Create Organization] + O --> OA[Create Organization Admin] + OA --> L[Organization Admin Login] + L --> E[Create Event] + E --> P[Upload Participants] + P --> INV[Send Enrollment Invitations] + INV --> SELF[Participant Captures Selfie] + SELF --> ENR[Create Face Enrollment] + + E --> UP[Upload Event Photos] + UP --> S3[S3 Private Storage] + S3 --> K[Kafka Processing Queue] + K --> ML[RetinaFace + AdaFace] + ML --> M[Match Participants] + ENR --> M + M --> R{Confidence} + R -->|High| A[Auto Match] + R -->|Medium| REV[Review] + R -->|Low| U[Unknown] + A --> G[Build Gallery] + REV --> G + G --> EM[Send Result Email] + EM --> PG[Participant Private Gallery] + PG --> D[View / Download] +``` + +--- + +# 120. Admin-Level Architecture + +```mermaid +flowchart TD + FDX[FDX] --> AUTH[Single Authentication] + AUTH --> ROLE{Role Resolver} + ROLE --> ADMIN[Super Admin Frontend] + ROLE --> ORG[Organization Frontend] + + ADMIN --> API[NGINX / FastAPI] + ORG --> API + + API --> PG[(PostgreSQL)] + API --> REDIS[(Redis)] + API --> KAFKA[Kafka] + API --> S3[(S3)] + + KAFKA --> ML[ML Worker] + KAFKA --> EMAIL[Email Worker] + KAFKA --> RET[Retention Worker] + + ML --> PG + ML --> S3 + EMAIL --> PROVIDER[Resend / SES] + RET --> PG + RET --> S3 +``` + +--- + +# 121. Recommended Build Order + +## Phase 1 — Domain + Auth + +Build: + +- PostgreSQL schema +- migrations +- organizations +- users +- single login +- JWT/refresh +- Super Admin Organization CRUD +- Organization user invitation +- tenant isolation + +Acceptance: + +- Admin creates Organization +- Admin creates Organization Admin +- Organization Admin logs in +- cannot access another Organization + +--- + +## Phase 2 — Events + Participants + +Build: + +- event CRUD +- participant import +- import validation +- participant UI +- enrollment token +- enrollment emails +- public enrollment page + +Acceptance: + +- Organization creates event +- imports participants +- participants receive link +- participant submits valid selfie + +--- + +## Phase 3 — Media Upload + Storage + +Build: + +- S3 +- upload batches +- quota reservations +- presigned uploads +- media validation +- thumbnails + +Acceptance: + +- upload large event folder without proxying bytes through FastAPI +- quota enforced + +--- + +## Phase 4 — Kafka + ML + +Build: + +- Kafka +- processing jobs +- outbox +- ML worker +- RetinaFace/AdaFace integration +- embeddings +- match policy +- processing dashboard + +Acceptance: + +- uploaded photos process asynchronously +- matches saved +- failures retry +- no cross-tenant matching + +--- + +## Phase 5 — Review + Galleries + Email + +Build: + +- review UI +- galleries +- gallery token +- result emails +- private download links +- delivery tracking + +Acceptance: + +- participant receives only matched photos +- gallery access expires correctly + +--- + +## Phase 6 — Retention + Admin Operations + +Build: + +- retention worker +- expiry +- storage ledger reconciliation +- admin jobs page +- audit logs +- account expiry +- data deletion + +Acceptance: + +- expired event becomes inaccessible +- storage cleaned +- audit written + +--- + +## Phase 7 — Production Hardening + +Build: + +- deployment +- Docker +- managed dependencies +- rate limiting +- security headers +- monitoring +- backups +- CI/CD +- load tests +- security tests +- ML calibration + +--- + +# 122. Acceptance Criteria — Authentication + +- [ ] One login page exists. +- [ ] `super_admin` routes to `/admin`. +- [ ] `org_admin` routes to `/organization`. +- [ ] Access token expires. +- [ ] Refresh token rotates. +- [ ] Logout revokes session. +- [ ] Passwords use secure hashing. +- [ ] Invitation token is single use. +- [ ] Expired invitation is rejected. +- [ ] Cross-tenant access fails. + +--- + +# 123. Acceptance Criteria — Super Admin + +- [ ] Create College Organization. +- [ ] Create Company Organization. +- [ ] Set storage quota. +- [ ] Set retention period. +- [ ] Set account expiry. +- [ ] Create Organization Admin. +- [ ] Suspend Organization. +- [ ] Reactivate Organization. +- [ ] View global usage. +- [ ] View jobs/failures. +- [ ] View audit logs. + +--- + +# 124. Acceptance Criteria — Organization Admin + +- [ ] Create event. +- [ ] Update event. +- [ ] Upload CSV/XLSX participants. +- [ ] Preview invalid rows. +- [ ] Confirm import. +- [ ] Send enrollment invitations. +- [ ] See enrollment status. +- [ ] Upload event folder. +- [ ] See upload progress. +- [ ] Start processing. +- [ ] See ML progress. +- [ ] Review uncertain matches. +- [ ] Build galleries. +- [ ] Send result emails. +- [ ] View delivery status. +- [ ] View Organization logs. + +--- + +# 125. Acceptance Criteria — Participant + +- [ ] Participant does not need an account. +- [ ] Secure enrollment URL works. +- [ ] Expired enrollment URL fails. +- [ ] Consent is captured. +- [ ] Camera capture works. +- [ ] Invalid face asks for retake. +- [ ] Valid face generates enrollment. +- [ ] Result email contains private gallery link. +- [ ] Gallery contains only authorized photos. +- [ ] Gallery expires. +- [ ] Signed S3 URLs expire. +- [ ] Download works while authorized. + +--- + +# 126. Acceptance Criteria — ML + +- [ ] RetinaFace R50 model loaded. +- [ ] AdaFace IR101 model loaded. +- [ ] Embedding dimension is 512. +- [ ] Embeddings are L2-normalized. +- [ ] Cosine similarity used. +- [ ] Model/version recorded. +- [ ] High-confidence matches can auto-accept. +- [ ] Medium-confidence matches go to review. +- [ ] Low-confidence faces remain unknown. +- [ ] Low-resolution policy supported. +- [ ] Competing identity safety checks retained. +- [ ] Thresholds configurable. +- [ ] Calibration performed before production release. + +--- + +# 127. Acceptance Criteria — Infrastructure + +- [ ] PostgreSQL durable source of truth. +- [ ] Redis is not sole durable store. +- [ ] Kafka consumers idempotent. +- [ ] DLQ exists for critical consumers. +- [ ] S3 is private. +- [ ] Presigned upload used. +- [ ] Presigned download used. +- [ ] Storage quota enforced. +- [ ] S3 encryption enabled. +- [ ] Retention worker deletes expired data. +- [ ] Audit logs written. +- [ ] Health endpoints exist. +- [ ] Backups configured. +- [ ] Restore tested. +- [ ] Staging separate from production. + +--- + +# 128. Definition of Done + +FDX V2 is considered functionally complete for the current scope only when this entire sequence works in a production-like staging environment: + +```text +Super Admin logs in + ↓ +Creates College/Company + ↓ +Creates Organization Admin + ↓ +Organization Admin activates account + ↓ +Organization Admin logs in + ↓ +Creates Event + ↓ +Imports Participants + ↓ +Enrollment emails are sent + ↓ +Participants enroll faces + ↓ +Organization uploads event-photo folder + ↓ +Storage quota is enforced + ↓ +Photos are stored privately + ↓ +Kafka queues processing + ↓ +GPU/CPU ML worker detects faces + ↓ +AdaFace embeddings are generated + ↓ +Participant embeddings are compared + ↓ +Matches are classified + ↓ +Review items are handled + ↓ +Private galleries are generated + ↓ +Result emails are delivered + ↓ +Participants open private galleries + ↓ +Participants view/download only their matched photos + ↓ +Retention deadline arrives + ↓ +Links become invalid + ↓ +Photos/biometric data are deleted or archived only according to policy + ↓ +Storage usage is updated + ↓ +Audit trail records the lifecycle +``` + +--- + +# 129. Coverage Matrix + +This matrix verifies that all previously discussed workflow elements are included. + +| Previously discussed requirement | Covered in this document | +|---|---| +| One login | Sections 7–8 | +| JWT authentication | Sections 8, 52 | +| Super Admin | Sections 5, 9, 14, 53 | +| College/Company Admin | Sections 5, 16, 54+ | +| College + Company unified as Organization | Sections 2, 4, 11 | +| Create Organization | Sections 12, 53 | +| Create Organization users | Sections 13, 53 | +| Storage management | Sections 15, 28, 111 | +| Data expiry | Sections 15, 76–77, 108 | +| Tenant isolation | Section 10 | +| Organization dashboard | Section 16 | +| Create event | Sections 17–18 | +| Participant CSV/Excel | Sections 19, 56 | +| Participant email invite | Sections 21, 41 | +| Unique face-capture link | Sections 21–23 | +| Participant does not need account | Sections 5, 22 | +| Selfie capture | Section 22 | +| Face alignment/embedding | Sections 24, 29 | +| Upload event folder | Sections 25–27 | +| S3 object storage | Sections 27, 86 | +| Kafka | Sections 35–38 | +| Redis | Section 39 | +| RetinaFace | Sections 29–31 | +| AdaFace | Sections 24, 29–31 | +| 512-dimensional embeddings | Sections 24, 48 | +| Cosine matching | Sections 24, 29–31 | +| High/medium/low confidence | Sections 31–33 | +| Results dashboard | Sections 60, 104–105 | +| Private gallery | Sections 44–46 | +| Expiring signed URL | Sections 45–46 | +| Result email | Sections 41, 62 | +| View/download | Sections 46, 117 | +| NGINX | Sections 71, 84 | +| FastAPI | Sections 64–65, 84 | +| PostgreSQL | Sections 47–48 | +| Docker | Section 85 | +| EC2 | Section 86 | +| Lambda | Section 86 | +| Glacier | Section 27.2, 86 | +| Resend | Sections 40, 86 | +| AWS SES | Sections 40, 86 | +| Logs | Sections 74, 100 | +| DB schema | Sections 47–48 | +| API endpoints | Sections 49–63 | +| Kafka topics/jobs | Sections 35–38 | +| Redis usage | Section 39 | +| S3 structure | Section 27 | +| Authentication/RBAC | Sections 7–10 | +| Email flows | Sections 40–43 | +| ML pipeline | Sections 29–33 | +| Deployment architecture | Sections 84–89 | +| CI/CD | Section 98 | +| Testing | Sections 96–97 | +| Backup/DR | Sections 91–92 | +| Security/privacy | Sections 72–76 | +| Error/retry handling | Sections 78–80 | +| Developer implementation phases | Section 121 | +| Acceptance criteria | Sections 122–128 | + +--- + +# 130. Final Architectural Decision Summary + +FDX V2 should be implemented as: + +```text +Frontend: +React + Vite + React Router + +Authentication: +Single login +JWT access token +Rotating refresh session +RBAC + +Backend: +FastAPI modular monolith + +Database: +PostgreSQL + pgvector + +Cache / coordination: +Redis + +Async messaging: +Kafka + +Media: +Private AWS S3 + +ML: +RetinaFace R50 +AdaFace IR101 +ONNX Runtime +CUDA where available +CPU fallback where permitted + +Email: +Provider abstraction +Resend OR AWS SES + +Workers: +ML +Email +Retention +Outbox +Thumbnail/Export as needed + +Deployment: +Dockerized application services +GPU worker separately scalable +AWS-compatible deployment +NGINX / load balancer +``` + +--- + +# 131. Critical Implementation Rules + +A developer working on FDX V2 must not violate the following rules: + +1. Do not create separate College and Company backend models. +2. Do not create separate login systems. +3. Do not trust Organization IDs from the frontend. +4. Do not make S3 event photos public. +5. Do not send raw image bytes through Kafka. +6. Do not store critical state only in Redis. +7. Do not run full-event ML processing synchronously in an API request. +8. Do not auto-match weak faces merely to increase match count. +9. Do not compare embeddings from incompatible models. +10. Do not expose participant biometric information in logs. +11. Do not retain expired biometric data outside policy. +12. Do not allow a participant gallery to access another participant's photos. +13. Do not treat a similarity score as a probability. +14. Do not allow one Organization to query another Organization's resources. +15. Do not mark a job successful before durable state is written. +16. Do not use provider-specific email logic throughout the application. +17. Do not rely on frontend route protection as security. +18. Do not bypass storage quota checks for multipart uploads. +19. Do not delete large event datasets synchronously from the user request. +20. Do not deploy new ML model versions without regression and calibration checks. + +--- + +# 132. Developer Handoff Checklist + +Before backend implementation begins: + +- [ ] Confirm environment names. +- [ ] Confirm production domain. +- [ ] Choose primary email provider: Resend or SES. +- [ ] Choose Kafka deployment: MSK, Confluent, or self-managed. +- [ ] Choose Redis deployment. +- [ ] Choose PostgreSQL deployment. +- [ ] Create S3 buckets/prefix policy. +- [ ] Configure KMS if used. +- [ ] Finalize participant consent text with appropriate legal/privacy review. +- [ ] Pin RetinaFace/AdaFace model files and checksums. +- [ ] Create threshold configuration. +- [ ] Create migration from `college` role to `org_admin`. +- [ ] Create `/college` compatibility redirect if needed. +- [ ] Implement database migrations. +- [ ] Implement tenant isolation tests before building feature breadth. + +--- + +# 133. Implementation Priority + +If only one rule is used to prioritize development, use this order: + +```text +Security / tenant isolation + ↓ +Correct data model + ↓ +Authentication + ↓ +Event + participant workflow + ↓ +Private media upload + ↓ +Reliable asynchronous processing + ↓ +Correct conservative face matching + ↓ +Private gallery delivery + ↓ +Retention / deletion + ↓ +Observability / scaling +``` + +A polished frontend should not be considered complete if backend isolation, storage privacy, matching correctness, or retention behavior is incomplete. + +--- + +# 134. Final Product Workflow in One Sentence + +> **An Organization creates an event, uploads attendee identities and event photographs, FDX securely enrolls attendees' faces, asynchronously processes the event gallery using RetinaFace and AdaFace, conservatively matches each attendee to photographs containing them, and privately delivers each attendee only their matched photographs, while the FDX Super Admin centrally manages Organizations, users, storage, retention, expiry, jobs, and platform health.** + +--- + +# End of FDX V2 Technical Specification diff --git a/tools/verify_v2.mjs b/tools/verify_v2.mjs new file mode 100644 index 0000000..3e10ae0 --- /dev/null +++ b/tools/verify_v2.mjs @@ -0,0 +1,190 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; + +const origin = process.env.FDX_ORIGIN || "http://127.0.0.1:8080"; +const base = `${origin}/api/v2`; +const facePath = process.env.FDX_VERIFY_FACE_IMAGE; +if (!facePath) throw new Error("FDX_VERIFY_FACE_IMAGE must point to a clear JPEG face image"); +const face = readFileSync(facePath); + +function cookie(response) { + return response.headers.get("set-cookie")?.split(";", 1)[0] || ""; +} + +async function call(path, { token, cookie: sessionCookie, expected = 200, ...options } = {}) { + const headers = new Headers(options.headers || {}); + if (token) headers.set("authorization", `Bearer ${token}`); + if (sessionCookie) headers.set("cookie", sessionCookie); + if (options.body && typeof options.body === "string" && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const response = await fetch(`${base}${path}`, { ...options, headers }); + const text = response.status === 204 ? "" : await response.text(); + const payload = text ? JSON.parse(text) : null; + if (response.status !== expected) { + throw new Error(`${options.method || "GET"} ${path}: expected ${expected}, received ${response.status}: ${text}`); + } + return { response, payload, data: payload?.data }; +} + +const suffix = Date.now(); +const login = await call("/auth/login", { + method: "POST", + body: JSON.stringify({ email: process.env.FDX_SUPER_ADMIN_EMAIL || "superadmin@fdx.io", password: process.env.FDX_SUPER_ADMIN_PASSWORD || "SuperAdmin@123" }), +}); +const firstAccess = login.data.access_token; +const firstRefresh = cookie(login.response); +const rotated = await call("/auth/refresh", { method: "POST", cookie: firstRefresh }); +const adminToken = rotated.data.access_token; +const adminRefresh = cookie(rotated.response); +await call("/auth/me", { token: firstAccess, expected: 401 }); +await call("/auth/refresh", { method: "POST", cookie: firstRefresh, expected: 401 }); + +async function createOrganization(label) { + const organization = await call("/admin/organizations", { + method: "POST", + token: adminToken, + body: JSON.stringify({ + name: `FDX V2 ${label} ${suffix}`, + organization_type: "COMPANY", + primary_email: `${label.toLowerCase()}-${suffix}@example.com`, + contact_name: `${label} Owner`, + storage_limit_bytes: 10 * 1024 * 1024, + default_retention_days: 30, + }), + expected: 201, + }); + const invitation = await call(`/admin/organizations/${organization.data.id}/users`, { + method: "POST", + token: adminToken, + body: JSON.stringify({ name: `${label} Admin`, email: `${label.toLowerCase()}-admin-${suffix}@example.com` }), + expected: 201, + }); + const invitationToken = invitation.data.development_invitation_url.split("/").pop(); + const accepted = await call(`/auth/invitations/${invitationToken}/accept`, { + method: "POST", + body: JSON.stringify({ password: "VerificationPass@123" }), + }); + await call(`/auth/invitations/${invitationToken}/accept`, { + method: "POST", + body: JSON.stringify({ password: "VerificationPass@123" }), + expected: 404, + }); + return { organization: organization.data, token: accepted.data.access_token, refresh: cookie(accepted.response) }; +} + +const tenantA = await createOrganization("Alpha"); +const tenantB = await createOrganization("Beta"); +const startsAt = new Date(Date.now() + 86_400_000).toISOString(); +const event = await call("/events", { + method: "POST", + token: tenantA.token, + body: JSON.stringify({ name: `V2 verification ${suffix}`, description: "Automated V2 acceptance flow", starts_at: startsAt, retention_days: 30 }), + expected: 201, +}); +const eventId = event.data.id; +for (const probe of [ + ["GET", `/events/${eventId}`], + ["PATCH", `/events/${eventId}`], + ["POST", `/events/${eventId}/upload-batches`], +]) { + const body = probe[0] === "PATCH" ? JSON.stringify({ name: "Cross-tenant mutation" }) : probe[0] === "POST" ? JSON.stringify({ expected_files: 1, reserved_bytes: face.length }) : undefined; + await call(probe[1], { method: probe[0], token: tenantB.token, body, expected: 404 }); +} + +await call(`/events/${eventId}/open-enrollment`, { method: "POST", token: tenantA.token }); +const participantFile = new FormData(); +participantFile.append("file", new Blob([`Name,Email\nV2 Participant,participant-${suffix}@example.com\nBroken,invalid-email\n`], { type: "text/csv" }), "participants.csv"); +const preview = await call(`/events/${eventId}/participant-imports`, { method: "POST", token: tenantA.token, body: participantFile, expected: 201 }); +if (preview.data.valid_rows !== 1 || preview.data.invalid_rows !== 1) throw new Error("Participant preview validation did not classify rows correctly"); +const importKey = randomUUID(); +const confirmed = await call(`/events/${eventId}/participant-imports/${preview.data.id}/confirm`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": importKey }, expected: 201 }); +const repeatedConfirm = await call(`/events/${eventId}/participant-imports/${preview.data.id}/confirm`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": importKey }, expected: 201 }); +if (confirmed.data.participants_created !== 1 || repeatedConfirm.data.participants_created !== 1) throw new Error("Import confirmation idempotency failed"); +const enrollmentToken = confirmed.data.development_invitations[0].url.split("/").pop(); +await call(`/public/enrollment/${enrollmentToken}`); +const consent = new FormData(); +consent.append("accepted", "true"); +await call(`/public/enrollment/${enrollmentToken}/consent`, { method: "POST", body: consent, expected: 201 }); +const selfie = new FormData(); +selfie.append("selfie", new Blob([face], { type: "image/jpeg" }), "face.jpg"); +const enrollment = await call(`/public/enrollment/${enrollmentToken}/complete`, { method: "POST", body: selfie }); +if (enrollment.data.embedding_dimension !== 512) throw new Error("Enrollment embedding was not 512-dimensional"); +await call(`/public/enrollment/${enrollmentToken}`, { expected: 404 }); + +await call(`/events/${eventId}/close-enrollment`, { method: "POST", token: tenantA.token }); +const batch = await call(`/events/${eventId}/upload-batches`, { + method: "POST", + token: tenantA.token, + body: JSON.stringify({ expected_files: 1, reserved_bytes: face.length }), + expected: 201, +}); +const digest = createHash("sha256").update(face).digest("hex"); +const presigned = await call(`/events/${eventId}/upload-batches/${batch.data.id}/presign`, { + method: "POST", + token: tenantA.token, + body: JSON.stringify({ files: [{ filename: "folder/face.jpg", content_type: "image/jpeg", size_bytes: face.length, sha256: digest }] }), +}); +const upload = presigned.data.files[0]; +const uploadResponse = await fetch(upload.upload_url.startsWith("http") ? upload.upload_url : `${origin}${upload.upload_url}`, { + method: "PUT", + headers: { ...upload.headers, authorization: `Bearer ${tenantA.token}` }, + body: face, +}); +if (!uploadResponse.ok) throw new Error(`Direct upload failed: ${uploadResponse.status}`); +const completeKey = randomUUID(); +const complete = await call(`/events/${eventId}/upload-batches/${batch.data.id}/complete`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": completeKey }, expected: 202 }); +const completeAgain = await call(`/events/${eventId}/upload-batches/${batch.data.id}/complete`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": completeKey }, expected: 202 }); +if (complete.data.jobs[0] !== completeAgain.data.jobs[0]) throw new Error("Upload completion idempotency failed"); + +let processing; +for (let attempt = 0; attempt < 60; attempt += 1) { + processing = await call(`/events/${eventId}/processing`, { token: tenantA.token }); + if (processing.data.progress_percent === 100) break; + await new Promise((resolve) => setTimeout(resolve, 1000)); +} +if (processing.data.progress_percent !== 100) throw new Error(`ML processing did not finish: ${JSON.stringify(processing.data)}`); +const matches = await call(`/events/${eventId}/matches`, { token: tenantA.token }); +if (!matches.data.some((item) => ["high", "approved"].includes(item.decision))) throw new Error("Identical enrollment/event image did not produce an accepted match"); +const galleryBuild = await call(`/events/${eventId}/galleries/build`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": randomUUID() }, expected: 202 }); +if (galleryBuild.data.galleries_ready !== 1) throw new Error("Gallery was not built"); +const delivery = await call(`/events/${eventId}/deliveries/send`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": randomUUID() }, expected: 202 }); +const galleryToken = delivery.data.development_gallery_urls[0].url.split("/").pop(); +const gallery = await call(`/public/gallery/${galleryToken}`); +if (gallery.data.photos.length !== 1) throw new Error("Private gallery did not contain exactly the matched media"); +const download = await call(`/public/gallery/${galleryToken}/download-url`, { method: "POST", body: JSON.stringify({ media_id: gallery.data.photos[0].id }) }); +if (!download.data.url) throw new Error("Authorized gallery download URL was not issued"); +const exportRequest = await call(`/public/gallery/${galleryToken}/exports`, { method: "POST", expected: 202 }); +let exportStatus; +for (let attempt = 0; attempt < 30; attempt += 1) { + exportStatus = await call(`/public/gallery/${galleryToken}/exports/${exportRequest.data.export_id}`); + if (exportStatus.data.status === "READY") break; + await new Promise((resolve) => setTimeout(resolve, 1000)); +} +if (exportStatus.data.status !== "READY") throw new Error(`Gallery ZIP export did not finish: ${JSON.stringify(exportStatus.data)}`); +const exportDownload = await fetch(exportStatus.data.download_url.startsWith("http") ? exportStatus.data.download_url : `${origin}${exportStatus.data.download_url}`); +const exportBytes = Buffer.from(await exportDownload.arrayBuffer()); +if (!exportDownload.ok || exportBytes.subarray(0, 2).toString() !== "PK") throw new Error("Gallery ZIP download was invalid"); + +await call(`/events/${eventId}`, { method: "DELETE", token: tenantA.token, expected: 202 }); +await call("/auth/logout", { method: "POST", token: tenantA.token, cookie: tenantA.refresh, expected: 204 }); +await call("/auth/me", { token: tenantA.token, expected: 401 }); +await call(`/admin/organizations/${tenantA.organization.id}/schedule-deletion`, { method: "POST", token: adminToken, expected: 202 }); +await call(`/admin/organizations/${tenantB.organization.id}/schedule-deletion`, { method: "POST", token: adminToken, expected: 202 }); +await call("/auth/logout", { method: "POST", token: adminToken, cookie: adminRefresh, expected: 204 }); + +console.log(JSON.stringify({ + status: "passed", + refresh_rotation: true, + single_use_invitation: true, + tenant_isolation_statuses: [404, 404, 404], + import_preview: { valid: preview.data.valid_rows, invalid: preview.data.invalid_rows }, + idempotency: true, + embedding_dimension: enrollment.data.embedding_dimension, + processing: processing.data, + gallery_photos: gallery.data.photos.length, + gallery_export_bytes: exportBytes.length, + deletion_scheduled: true, + verification_tenants_scheduled_for_deletion: true, + logout_revocation: true, +}, null, 2)); diff --git a/webapp/src/App.jsx b/webapp/src/App.jsx index a0f811c..b1712af 100644 --- a/webapp/src/App.jsx +++ b/webapp/src/App.jsx @@ -24,6 +24,8 @@ import EmailOutbox from "./pages/organization/EmailOutbox"; import AcceptInvite from "./pages/public/AcceptInvite"; import Enrollment from "./pages/public/Enrollment"; import Gallery from "./pages/public/Gallery"; +import ForgotPassword from "./pages/public/ForgotPassword"; +import ResetPassword from "./pages/public/ResetPassword"; const SUPER_ADMIN_NAV = [ { to: "/admin", label: "Overview", icon: "dashboard", end: true }, @@ -69,6 +71,8 @@ export default function App() { } /> } /> } /> + } /> + } /> ; diff --git a/webapp/src/context/AuthContext.jsx b/webapp/src/context/AuthContext.jsx index 1a9caa6..c3413aa 100644 --- a/webapp/src/context/AuthContext.jsx +++ b/webapp/src/context/AuthContext.jsx @@ -1,19 +1,27 @@ /* oxlint-disable react/only-export-components -- Provider and hook form one public context API. */ import { createContext, useContext, useEffect, useMemo, useState } from "react"; -import { clearSession, getStoredSession, loginRequest, storeSession } from "../lib/api"; +import { initializeSession, loginRequest, logoutRequest, storeSession } from "../lib/api"; const AuthContext = createContext(null); export function AuthProvider({ children }) { - const [session, setSession] = useState(() => getStoredSession()); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { const clear = () => setSession(null); + const refreshed = (event) => setSession(event.detail); window.addEventListener("fdx:session-cleared", clear); - return () => window.removeEventListener("fdx:session-cleared", clear); + window.addEventListener("fdx:session-refreshed", refreshed); + initializeSession().then(setSession).finally(() => setLoading(false)); + return () => { + window.removeEventListener("fdx:session-cleared", clear); + window.removeEventListener("fdx:session-refreshed", refreshed); + }; }, []); const value = useMemo(() => ({ user: session?.user ?? null, isAuthenticated: Boolean(session?.user && session?.token), + loading, async login(email, password) { const nextSession = await loginRequest(email, password); storeSession(nextSession); @@ -21,14 +29,14 @@ export function AuthProvider({ children }) { return nextSession; }, setAuthenticatedSession(nextSession) { - storeSession(nextSession); - setSession(nextSession); + const normalized = storeSession(nextSession); + setSession(normalized); }, - logout() { - clearSession(); + async logout() { + await logoutRequest(); setSession(null); }, - }), [session]); + }), [loading, session]); return {children}; } diff --git a/webapp/src/context/PlatformContext.jsx b/webapp/src/context/PlatformContext.jsx index a567c3a..c9801a3 100644 --- a/webapp/src/context/PlatformContext.jsx +++ b/webapp/src/context/PlatformContext.jsx @@ -7,10 +7,16 @@ import { useMemo, useState, } from "react"; -import { api } from "../lib/api"; +import { api, directUpload } from "../lib/api"; import { useAuth } from "./AuthContext"; const PlatformContext = createContext(null); +const uuid = () => crypto.randomUUID(); + +async function checksum(file) { + const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer()); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} const initialState = { organizations: [], organizationUsers: [], @@ -161,16 +167,54 @@ export function PlatformProvider({ children }) { mutate(`/organization/emails/${id}/retry`, { method: "POST" }), adminRetryEmail: (id) => mutate(`/admin/emails/${id}/retry`, { method: "POST" }), - importParticipants: (eventId, file) => { + validateParticipantImport: async (eventId, file) => { const body = new FormData(); - body.append("event_id", eventId); body.append("file", file); - return mutate("/organization/participants/import", { + const response = await api(`/v2/events/${eventId}/participant-imports`, { method: "POST", body, }); + return response.data; + }, + confirmParticipantImport: async (eventId, importId) => { + const response = await api(`/v2/events/${eventId}/participant-imports/${importId}/confirm`, { + method: "POST", + headers: { "Idempotency-Key": uuid() }, + }); + await refresh(); + return response.data; + }, + uploadPhotos: async (eventId, files) => { + if (files.some((file) => file.name.toLowerCase().endsWith(".zip"))) { + const body = new FormData(); + body.append("event_id", eventId); + files.forEach((file) => body.append("files", file)); + return mutate("/organization/photos", { method: "POST", body }); + } + const manifest = await Promise.all(files.map(async (file) => ({ + filename: file.webkitRelativePath || file.name, + content_type: file.type, + size_bytes: file.size, + sha256: await checksum(file), + }))); + const reservation = await api(`/v2/events/${eventId}/upload-batches`, { + method: "POST", + body: JSON.stringify({ expected_files: files.length, reserved_bytes: files.reduce((sum, file) => sum + file.size, 0) }), + }); + const batchId = reservation.data.id; + const presigned = await api(`/v2/events/${eventId}/upload-batches/${batchId}/presign`, { + method: "POST", + body: JSON.stringify({ files: manifest }), + }); + await Promise.all(presigned.data.files.map((target, index) => directUpload(target.upload_url, files[index], target.headers))); + const completed = await api(`/v2/events/${eventId}/upload-batches/${batchId}/complete`, { + method: "POST", + headers: { "Idempotency-Key": uuid() }, + }); + await refresh(); + return { uploaded: completed.data.jobs, jobsPublished: completed.data.jobs.length, skipped: [] }; }, - uploadPhotos: (eventId, files) => { + uploadPhotosLegacy: (eventId, files) => { const body = new FormData(); body.append("event_id", eventId); files.forEach((file) => body.append("files", file)); diff --git a/webapp/src/index.css b/webapp/src/index.css index 0c04bcc..1e79d3a 100644 --- a/webapp/src/index.css +++ b/webapp/src/index.css @@ -470,6 +470,7 @@ tbody tr:hover { .policy-chips { display: flex; gap: 8px; flex-wrap: wrap; } .stat-grid-wide { grid-template-columns: repeat(4, minmax(180px, 1fr)); } .text-link { color: var(--violet-600); text-decoration: none; font-size: 13px; font-weight: 600; } +.success-text { color: var(--success); font-size: 13px; } .usage-list, .service-list, .event-list { display: grid; gap: 2px; } .usage-row { display: flex; align-items: center; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--border); } .usage-row:last-child { border-bottom: 0; } @@ -573,5 +574,5 @@ tbody tr:hover { .page-state { min-height: 180px; padding: 32px; display:grid; place-items:center; align-content:center; gap:8px; text-align:center; color:var(--muted); } .page-state.error { color:var(--danger); background:var(--danger-bg); }.page-state p{font-size:13px}.state-spinner{width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--violet-500);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} .public-shell { min-height:100vh;display:grid;place-items:center;padding:24px;background:var(--gradient-mesh); }.public-card{width:min(620px,100%);padding:28px;display:grid;gap:18px}.public-brand{display:flex;align-items:center;gap:14px}.public-card h1{font-size:23px}.event-summary{display:grid;padding:14px;border-radius:var(--radius-md);background:var(--gradient-soft)}.event-summary span{font-size:12px;color:var(--muted)}.camera-frame{aspect-ratio:4/3;display:grid;place-items:center;overflow:hidden;border-radius:var(--radius-lg);background:#10152f}.camera-frame video,.camera-frame img{width:100%;height:100%;object-fit:cover}.camera-actions{display:flex;gap:8px;flex-wrap:wrap}.consent-row{display:flex;align-items:flex-start;gap:10px;padding:13px;border-radius:var(--radius-md);background:var(--surface-muted);font-size:12px;color:var(--muted)}.consent-row input{margin-top:3px}.success-view{text-align:center;justify-items:center}.success-mark{width:60px;height:60px;display:grid;place-items:center;border-radius:50%;background:var(--success-bg);color:var(--success)} -.gallery-page{min-height:100vh;padding:28px clamp(18px,5vw,72px);background:var(--bg)}.gallery-header{display:flex;align-items:center;gap:16px}.gallery-header h1{font-size:26px}.gallery-header p{color:var(--muted);font-size:13px}.gallery-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:28px 0 14px;color:var(--muted);font-size:12px}.gallery-toolbar>div{display:flex;align-items:center;gap:12px}.gallery-actions .btn{color:inherit;text-decoration:none}.gallery-actions .primary{color:#fff}.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}.photo-gallery figure{position:relative;margin:0;overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--surface);box-shadow:var(--shadow-sm)}.photo-gallery figure.selected{border-color:var(--violet-500);box-shadow:0 0 0 2px rgba(108,92,231,.18)}.photo-select{position:absolute;z-index:2;top:10px;right:10px;width:30px;height:30px;display:grid;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;background:rgba(20,27,66,.72);color:#fff;cursor:pointer}.photo-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}.photo-gallery figcaption{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;font-size:12px}.photo-gallery a{display:flex;align-items:center;gap:5px;color:var(--violet-600);text-decoration:none} +.gallery-page{min-height:100vh;padding:28px clamp(18px,5vw,72px);background:var(--bg)}.gallery-header{display:flex;align-items:center;gap:16px}.gallery-header h1{font-size:26px}.gallery-header p{color:var(--muted);font-size:13px}.gallery-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:28px 0 14px;color:var(--muted);font-size:12px}.gallery-toolbar>div{display:flex;align-items:center;gap:12px}.gallery-actions .btn{color:inherit;text-decoration:none}.gallery-actions .primary{color:#fff}.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}.photo-gallery figure{position:relative;margin:0;overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--surface);box-shadow:var(--shadow-sm)}.photo-gallery figure.selected{border-color:var(--violet-500);box-shadow:0 0 0 2px rgba(108,92,231,.18)}.photo-select{position:absolute;z-index:2;top:10px;right:10px;width:30px;height:30px;display:grid;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;background:rgba(20,27,66,.72);color:#fff;cursor:pointer}.photo-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}.photo-gallery figcaption{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;font-size:12px}.photo-gallery a,.photo-gallery .link-button{display:flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--violet-600);font:inherit;text-decoration:none;cursor:pointer} .btn.danger{background:var(--danger);border-color:var(--danger);color:#fff}.table-error{display:block;max-width:340px;margin-top:3px;color:var(--danger);font-size:10px;white-space:normal}.settings-nav button:disabled{opacity:.45;cursor:not-allowed} diff --git a/webapp/src/lib/api.js b/webapp/src/lib/api.js index c2068fc..54dbf1b 100644 --- a/webapp/src/lib/api.js +++ b/webapp/src/lib/api.js @@ -1,39 +1,140 @@ -const SESSION_KEY = "fdx.session"; +const USER_KEY = "fdx.user"; +let accessToken = null; +let refreshPromise = null; -export function getStoredSession() { +function normalizeUser(user) { + if (!user) return null; + return { + ...user, + organizationId: user.organizationId ?? user.organization_id ?? null, + organizationName: user.organizationName ?? user.organization_name ?? null, + }; +} + +function rememberUser(user) { + if (user) sessionStorage.setItem(USER_KEY, JSON.stringify(normalizeUser(user))); + else sessionStorage.removeItem(USER_KEY); +} + +function cachedUser() { try { - return JSON.parse(localStorage.getItem(SESSION_KEY)) || null; + return normalizeUser(JSON.parse(sessionStorage.getItem(USER_KEY))); } catch { return null; } } -export function storeSession(session) { - localStorage.setItem(SESSION_KEY, JSON.stringify(session)); +function normalizeV2Session(payload) { + const data = payload?.data ?? payload; + return { + token: data?.access_token ?? data?.token ?? null, + expiresIn: data?.expires_in, + user: normalizeUser(data?.user), + }; +} + +export function getStoredSession() { + const user = cachedUser(); + return user && accessToken ? { token: accessToken, user } : null; +} + +export function storeSession(rawSession) { + const session = normalizeV2Session(rawSession); + accessToken = session.token; + rememberUser(session.user); + return session; } export function clearSession() { - localStorage.removeItem(SESSION_KEY); + accessToken = null; + rememberUser(null); window.dispatchEvent(new Event("fdx:session-cleared")); } -export async function api(path, options = {}) { - const session = getStoredSession(); +async function refreshSession() { + if (!refreshPromise) { + refreshPromise = fetch("/api/v2/auth/refresh", { + method: "POST", + credentials: "include", + }) + .then(async (response) => { + if (!response.ok) throw new Error("Session expired"); + const session = storeSession(await response.json()); + window.dispatchEvent(new CustomEvent("fdx:session-refreshed", { detail: session })); + return session; + }) + .catch((error) => { + clearSession(); + throw error; + }) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +export async function initializeSession() { + try { + return await refreshSession(); + } catch { + return null; + } +} + +async function request(path, options = {}, retry = true) { const headers = new Headers(options.headers || {}); - if (session?.token) headers.set("Authorization", `Bearer ${session.token}`); + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); if (options.body && !(options.body instanceof FormData) && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } - const response = await fetch(`/api${path}`, { ...options, headers }); - if (response.status === 401) clearSession(); + const response = await fetch(path, { ...options, headers, credentials: "include" }); + if (response.status === 401 && retry && !path.endsWith("/auth/refresh")) { + await refreshSession(); + return request(path, options, false); + } const payload = response.headers.get("content-type")?.includes("application/json") ? await response.json() : null; if (!response.ok) { - const detail = Array.isArray(payload?.detail) ? payload.detail.map((item) => item.msg).join(" · ") : payload?.detail; + const validation = payload?.error?.details?.errors ?? payload?.detail; + const detail = Array.isArray(validation) + ? validation.map((item) => item.msg).join(" · ") + : payload?.error?.message ?? validation; throw new Error(detail || payload?.message || `Request failed (${response.status})`); } return payload; } -export function loginRequest(email, password) { - return api("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }); +export function api(path, options = {}) { + return request(`/api${path}`, options); +} + +export async function directUpload(url, file, headers = {}) { + if (url.startsWith("/")) { + return request(url, { method: "PUT", headers, body: file }, false); + } + + // Presigned object-storage URLs authenticate through their signature. Sending + // application cookies or the API bearer token would unnecessarily widen the + // browser CORS contract and can cause S3 to reject an otherwise valid PUT. + const response = await fetch(url, { method: "PUT", headers, body: file }); + if (!response.ok) throw new Error(`Direct upload failed (${response.status})`); + return null; +} + +export async function loginRequest(email, password) { + const payload = await request("/api/v2/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }, false); + return storeSession(payload); +} + +export async function logoutRequest() { + try { + await request("/api/v2/auth/logout", { method: "POST" }, false); + } catch { + // Local session state must still be cleared if the network is unavailable. + } finally { + clearSession(); + } } diff --git a/webapp/src/pages/Login.jsx b/webapp/src/pages/Login.jsx index df6b72b..95ed1f3 100644 --- a/webapp/src/pages/Login.jsx +++ b/webapp/src/pages/Login.jsx @@ -1,16 +1,18 @@ import { useState } from "react"; -import { Navigate, useNavigate } from "react-router-dom"; +import { Link, Navigate, useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; import "./Login.css"; export default function Login() { - const { login, isAuthenticated, user } = useAuth(); + const { login, isAuthenticated, loading, user } = useAuth(); const navigate = useNavigate(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); + if (loading) return null; + if (isAuthenticated) { return ; } @@ -73,6 +75,8 @@ export default function Login() { {submitting ? "Signing in..." : "Sign in"} + Forgot password? + diff --git a/webapp/src/pages/organization/Participants.jsx b/webapp/src/pages/organization/Participants.jsx index 42251e5..e45d797 100644 --- a/webapp/src/pages/organization/Participants.jsx +++ b/webapp/src/pages/organization/Participants.jsx @@ -6,12 +6,13 @@ import Modal from "../../components/Modal"; import StatCard from "../../components/StatCard"; import { usePlatform } from "../../context/PlatformContext"; export default function Participants() { - const { events, participants, importParticipants } = usePlatform(); + const { events, participants, validateParticipantImport, confirmParticipantImport } = usePlatform(); const [eventId, setEventId] = useState(""); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [queued, setQueued] = useState(null); const [notice, setNotice] = useState(""); + const [preview, setPreview] = useState(null); const selectedEvent = eventId || events[0]?.id || ""; const visible = useMemo( () => @@ -28,11 +29,17 @@ export default function Participants() { const invited = participants.filter((p) => p.enrollment === "invited").length; async function runImport() { if (!queued || !selectedEvent) return; - const result = await importParticipants(selectedEvent, queued); + if (!preview) { + const result = await validateParticipantImport(selectedEvent, queued); + setPreview(result); + return; + } + const result = await confirmParticipantImport(selectedEvent, preview.id); setNotice( - `${result.imported} imported · ${result.duplicates} duplicates · ${result.invalid} invalid`, + `${result.participants_created} participants imported and invitations queued`, ); setQueued(null); + setPreview(null); setOpen(false); } return ( @@ -166,7 +173,7 @@ export default function Participants() { disabled={!queued || !selectedEvent} onClick={runImport} > - Validate & import + {preview ? "Confirm import" : "Validate import"} } @@ -190,7 +197,7 @@ export default function Participants() { hint="Drop .csv, .xlsx or click to browse" accept=".csv,.xls,.xlsx,.xlsm" multiple={false} - onFiles={(files) => setQueued(files[0])} + onFiles={(files) => { setQueued(files[0]); setPreview(null); }} /> {queued ? (
@@ -201,6 +208,12 @@ export default function Participants() {
) : null} + {preview ? ( +
+ {preview.valid_rows} valid · {preview.duplicate_rows} duplicate · {preview.invalid_rows} invalid + {preview.errors?.slice(0, 5).map((row) =>

Row {row.row}: {row.errors.join(", ")}

)} +
+ ) : null} diff --git a/webapp/src/pages/public/AcceptInvite.jsx b/webapp/src/pages/public/AcceptInvite.jsx index c7d730b..08868e7 100644 --- a/webapp/src/pages/public/AcceptInvite.jsx +++ b/webapp/src/pages/public/AcceptInvite.jsx @@ -1,2 +1,2 @@ -import {useState} from "react";import {Link,useNavigate,useParams} from "react-router-dom";import {api} from "../../lib/api";import {useAuth} from "../../context/AuthContext"; -export default function AcceptInvite(){const{token}=useParams();const navigate=useNavigate();const{setAuthenticatedSession}=useAuth();const[password,setPassword]=useState("");const[confirm,setConfirm]=useState("");const[error,setError]=useState("");const[submitting,setSubmitting]=useState(false);async function submit(event){event.preventDefault();if(password!==confirm){setError("Passwords do not match.");return}setSubmitting(true);setError("");try{const session=await api(`/auth/invitations/${token}`,{method:"POST",body:JSON.stringify({password})});setAuthenticatedSession(session);navigate("/organization",{replace:true})}catch(err){setError(err.message)}finally{setSubmitting(false)}}return
FDX

Secure invitation

Create your password

Activate your Organization Admin account.

setPassword(e.target.value)}/>
setConfirm(e.target.value)}/>
{error?

{error}

:null}Back to sign in
} +import {useState} from "react";import {Link,useNavigate,useParams} from "react-router-dom";import {useAuth} from "../../context/AuthContext"; +export default function AcceptInvite(){const{token}=useParams();const navigate=useNavigate();const{setAuthenticatedSession}=useAuth();const[password,setPassword]=useState("");const[confirm,setConfirm]=useState("");const[error,setError]=useState("");const[submitting,setSubmitting]=useState(false);async function submit(event){event.preventDefault();if(password!==confirm){setError("Passwords do not match.");return}setSubmitting(true);setError("");try{const response=await fetch(`/api/v2/auth/invitations/${token}/accept`,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify({password})});const payload=await response.json();if(!response.ok)throw new Error(payload?.error?.message||"Invitation could not be accepted");setAuthenticatedSession(payload);navigate("/organization",{replace:true})}catch(err){setError(err.message)}finally{setSubmitting(false)}}return
FDX

Secure invitation

Create your password

Activate your Organization Admin account.

setPassword(e.target.value)}/>
setConfirm(e.target.value)}/>
{error?

{error}

:null}Back to sign in
} diff --git a/webapp/src/pages/public/Enrollment.jsx b/webapp/src/pages/public/Enrollment.jsx index ae8f499..07359ae 100644 --- a/webapp/src/pages/public/Enrollment.jsx +++ b/webapp/src/pages/public/Enrollment.jsx @@ -17,7 +17,7 @@ export default function Enrollment() { const [submitting, setSubmitting] = useState(false); useEffect(() => { - api(`/public/enroll/${token}`).then(setInfo).catch((requestError) => setError(requestError.message)); + api(`/v2/public/enrollment/${token}`).then((response) => setInfo(response.data)).catch((requestError) => setError(requestError.message)); return () => streamRef.current?.getTracks().forEach((track) => track.stop()); }, [token]); @@ -53,10 +53,15 @@ export default function Enrollment() { if (!image || !consent) return; setSubmitting(true); setError(""); - const body = new FormData(); - body.append("consent", "true"); - body.append("selfie", image); - try { await api(`/public/enroll/${token}`, { method: "POST", body }); setDone(true); } + const consentBody = new FormData(); + consentBody.append("accepted", "true"); + const selfieBody = new FormData(); + selfieBody.append("selfie", image); + try { + await api(`/v2/public/enrollment/${token}/consent`, { method: "POST", body: consentBody }); + await api(`/v2/public/enrollment/${token}/complete`, { method: "POST", body: selfieBody }); + setDone(true); + } catch (requestError) { setError(requestError.message); } finally { setSubmitting(false); } } @@ -64,7 +69,7 @@ export default function Enrollment() { if (done) return

Face verified securely

FDX will email your private gallery when matching is complete. No account is required.

; return
FDX

Participant verification

Find your event photos

- {info ?
{info.event}{info.organization} · For {info.participant}
: null} + {info ?
{info.event_name}{info.organization_name} · For {info.participant_name}
: null} {error ?

{error}

: null}
{preview ? Captured selfie :
{preview ? : <>}
diff --git a/webapp/src/pages/public/ForgotPassword.jsx b/webapp/src/pages/public/ForgotPassword.jsx new file mode 100644 index 0000000..38260cb --- /dev/null +++ b/webapp/src/pages/public/ForgotPassword.jsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; + +export default function ForgotPassword() { + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + async function submit(event) { + event.preventDefault(); + setError(""); + const response = await fetch("/api/v2/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const payload = await response.json(); + if (!response.ok) { + setError(payload?.error?.message ?? "Password reset could not be requested."); + return; + } + setMessage(payload.data.message); + } + + return ( +
+
+ FDX +

Account recovery

Reset password

Enter your account email to receive a secure reset link.

+
setEmail(event.target.value)} />
+ {message ?

{message}

: null} + {error ?

{error}

: null} + + Back to sign in +
+
+ ); +} diff --git a/webapp/src/pages/public/Gallery.jsx b/webapp/src/pages/public/Gallery.jsx index 7f328ba..0d34714 100644 --- a/webapp/src/pages/public/Gallery.jsx +++ b/webapp/src/pages/public/Gallery.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import Icon from "../../components/Icon"; import { api } from "../../lib/api"; @@ -6,22 +6,48 @@ import { api } from "../../lib/api"; export default function Gallery() { const { token } = useParams(); const [data, setData] = useState(null); - const [selected, setSelected] = useState([]); const [error, setError] = useState(""); + const [exportJob, setExportJob] = useState(null); + useEffect(() => { - api(`/public/gallery/${token}`) - .then(setData) + api(`/v2/public/gallery/${token}`) + .then((response) => setData(response.data)) .catch((requestError) => setError(requestError.message)); }, [token]); - const allSelected = - Boolean(data?.photos.length) && selected.length === data.photos.length; - const downloadUrl = `/api/public/gallery/${token}/download${selected.length ? `?photoIds=${encodeURIComponent(selected.join(","))}` : ""}`; - function toggle(id) { - setSelected((current) => - current.includes(id) - ? current.filter((value) => value !== id) - : [...current, id], - ); + + const pollExport = useCallback(async (exportId) => { + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await api(`/v2/public/gallery/${token}/exports/${exportId}`); + setExportJob(response.data); + if (response.data.status === "READY") return response.data; + if (["FAILED", "EXPIRED"].includes(response.data.status)) throw new Error(response.data.error || "Gallery export failed"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error("Gallery export is still being prepared. Please try again shortly."); + }, [token]); + + async function downloadAll() { + try { + setError(""); + const response = await api(`/v2/public/gallery/${token}/exports`, { method: "POST" }); + setExportJob(response.data); + const ready = await pollExport(response.data.export_id); + window.location.assign(ready.download_url); + } catch (requestError) { + setError(requestError.message); + } + } + + async function downloadPhoto(photo) { + try { + const response = await api(`/v2/public/gallery/${token}/download-url`, { + method: "POST", + body: JSON.stringify({ media_id: photo.id }), + }); + window.location.assign(response.data.url); + } catch (requestError) { + setError(requestError.message); + } } return ( @@ -30,12 +56,8 @@ export default function Gallery() { FDX

Private gallery

-

{data?.event ?? "Your event photos"}

-

- {data - ? `${data.participant} · ${data.organization}` - : "Loading securely…"} -

+

{data?.event_name ?? "Your event photos"}

+

{data ? data.organization_name : "Loading securely…"}

{error ?

{error}

: null} @@ -44,68 +66,31 @@ export default function Gallery() {
{data.photos.length} matched photos - - Expires {new Date(data.expiresAt).toLocaleDateString()} - + Expires {new Date(data.expires_at).toLocaleDateString()}
{data.photos.length ? (
- - +
) : null}
{data.photos.map((photo) => ( -
- - - {photo.filename} - +
+ {photo.filename}
{photo.filename} - +
))}
- {!data.photos.length ? ( -
- No approved photos are available. -
- ) : null} + {!data.photos.length ?
No approved photos are available.
: null} ) : null}
diff --git a/webapp/src/pages/public/ResetPassword.jsx b/webapp/src/pages/public/ResetPassword.jsx new file mode 100644 index 0000000..0078b2c --- /dev/null +++ b/webapp/src/pages/public/ResetPassword.jsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; + +export default function ResetPassword() { + const { token } = useParams(); + const navigate = useNavigate(); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + + async function submit(event) { + event.preventDefault(); + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + const response = await fetch("/api/v2/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }); + const payload = await response.json(); + if (!response.ok) { + setError(payload?.error?.message ?? "Password reset failed."); + return; + } + navigate("/login", { replace: true }); + } + + return ( +
+
+ FDX +

Secure reset

Choose a new password

+
setPassword(event.target.value)} />
+
setConfirm(event.target.value)} />
+ {error ?

{error}

: null} + + Cancel +
+
+ ); +} From 5e15159541662d7f2594ae6bd5ff666381b89def Mon Sep 17 00:00:00 2001 From: Dijo S Benelen Date: Thu, 13 Aug 2026 01:17:03 +0530 Subject: [PATCH 4/4] feat: close FDX V2 specification gaps --- .env.example | 10 + .github/workflows/ci.yml | 6 +- README.md | 8 + backend/alembic/env.py | 13 +- .../versions/20260812_01_complete_workflow.py | 35 +- .../20260812_02_email_delivery_link.py | 14 +- .../versions/20260813_03_v2_foundation.py | 275 +- .../versions/20260813_05_spec_verification.py | 32 + backend/app/auth.py | 70 +- backend/app/config.py | 23 +- backend/app/integrations.py | 136 +- backend/app/main.py | 1121 ++++++- backend/app/models.py | 36 +- backend/app/serializers.py | 70 +- backend/app/v2.py | 2692 +++++++++++++++-- backend/app/worker.py | 603 +++- backend/tests/test_security.py | 48 +- deploy/aws/platform.yml | 22 + deploy/aws/publish.sh | 2 +- docker-compose.aws.yml | 2 +- docs/spec-implementation.md | 19 +- face-processing/service/Dockerfile.gpu | 17 + ruff.toml | 6 + tools/verify_v2.mjs | 446 ++- webapp/package-lock.json | 17 + webapp/package.json | 3 + webapp/src/components/DashboardShell.css | 66 +- webapp/src/components/Dropzone.css | 4 +- webapp/src/components/Gauge.jsx | 9 +- webapp/src/components/Icon.jsx | 28 +- webapp/src/components/LogsTable.jsx | 4 +- webapp/src/components/Modal.css | 29 +- webapp/src/components/Modal.jsx | 28 +- webapp/src/components/PageState.jsx | 24 +- webapp/src/context/AuthContext.jsx | 52 +- webapp/src/context/PlatformContext.jsx | 131 +- webapp/src/index.css | 1348 ++++++++- webapp/src/lib/api.js | 56 +- webapp/src/pages/Login.jsx | 33 +- webapp/src/pages/organization/Logs.jsx | 13 +- webapp/src/pages/organization/Overview.jsx | 151 +- .../src/pages/organization/Participants.jsx | 27 +- webapp/src/pages/organization/Processing.jsx | 105 +- webapp/src/pages/public/AcceptInvite.jsx | 81 +- webapp/src/pages/public/Enrollment.jsx | 184 +- webapp/src/pages/public/ForgotPassword.jsx | 40 +- webapp/src/pages/public/Gallery.jsx | 65 +- webapp/src/pages/public/ResetPassword.jsx | 41 +- webapp/src/pages/superadmin/Logs.jsx | 13 +- .../pages/superadmin/OrganizationUsers.jsx | 195 +- webapp/src/pages/superadmin/Organizations.jsx | 362 ++- webapp/src/pages/superadmin/Overview.jsx | 182 +- webapp/vite.config.js | 8 +- 53 files changed, 7919 insertions(+), 1086 deletions(-) create mode 100644 backend/alembic/versions/20260813_05_spec_verification.py create mode 100644 face-processing/service/Dockerfile.gpu create mode 100644 ruff.toml diff --git a/.env.example b/.env.example index 77f9697..cc2ae55 100644 --- a/.env.example +++ b/.env.example @@ -36,11 +36,21 @@ FDX_EMBEDDER_MODEL_VERSION=adaface-ir101-ms1mv2-v1 MATCH_AUTO_THRESHOLD=0.85 MATCH_REVIEW_THRESHOLD=0.65 MATCH_RUNNER_UP_MARGIN=0.08 +MINIMUM_FACE_SIZE=40 +LOW_RESOLUTION_FACE_SIZE=80 +MINIMUM_DETECTOR_CONFIDENCE=0.60 +LOW_RESOLUTION_THRESHOLD_BOOST=0.05 THRESHOLD_PROFILE_VERSION=default-v1 +FDX_DEVICE=cpu # Workflow policy CONSENT_POLICY_VERSION=2026-08-13 UPLOAD_RESERVATION_MINUTES=60 MAX_UPLOAD_BYTES=107374182400 +MAX_MEDIA_FILE_BYTES=104857600 +MAX_ENROLLMENT_BYTES=15728640 +MAX_IMAGE_PIXELS=100000000 +MULTIPART_THRESHOLD_BYTES=20971520 +MULTIPART_PART_BYTES=8388608 RETENTION_SCHEDULER_ENABLED=true RETENTION_POLL_SECONDS=60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7d6726..a09f606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: cache: npm cache-dependency-path: webapp/package-lock.json - run: npm ci + - run: npm run format:check - run: npm run lint - run: npm run build - run: npm audit --audit-level=high @@ -53,7 +54,8 @@ jobs: cache: pip cache-dependency-path: backend/requirements.txt - run: pip install -r backend/requirements.txt ruff pytest pip-audit - - run: ruff check --select F,E9 backend + - run: ruff check backend + - run: ruff format --check backend - run: python -m compileall -q backend/app backend/alembic - run: alembic -c backend/alembic.ini upgrade head - run: pytest -q backend/tests @@ -69,6 +71,8 @@ jobs: - run: pip install cfn-lint - run: docker compose config --quiet - run: cfn-lint deploy/aws/platform.yml + - run: sudo apt-get update && sudo apt-get install -y shellcheck + - run: find . -path './.venv' -prune -o -name '*.sh' -type f -print0 | xargs -0 shellcheck - run: bash -n run-platform.sh stop-platform.sh - run: sh -n deploy/aws/publish.sh tools/verify_models.sh backend/entrypoint.sh diff --git a/README.md b/README.md index 034509b..94b416f 100644 --- a/README.md +++ b/README.md @@ -98,4 +98,12 @@ npm run build `deploy/aws/platform.yml` provisions the production baseline: VPC, HTTPS ALB, EC2 Auto Scaling, RDS PostgreSQL, ElastiCache Redis, MSK Kafka, ECR, S3/Glacier, SES/IAM, Secrets Manager, SSM, and scheduled Lambda retention. +For an NVIDIA worker host with NVIDIA Container Toolkit installed, build the dedicated CUDA image and set `ML_IMAGE` and `FDX_DEVICE=cuda` in the production environment: + +```sh +docker build -f face-processing/service/Dockerfile.gpu -t fdx-ml:gpu . +``` + +The regular ML Dockerfile remains the CPU image; both variants serve inference through Gunicorn. + See [`deploy/aws/README.md`](deploy/aws/README.md) for deployment and image publishing commands. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 62c24c2..f92cedd 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -14,13 +14,22 @@ def run_migrations_offline(): - context.configure(url=settings.database_url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}) + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): - connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool) + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) with connectable.connect() as connection: context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): diff --git a/backend/alembic/versions/20260812_01_complete_workflow.py b/backend/alembic/versions/20260812_01_complete_workflow.py index 53c7d61..1e368b5 100644 --- a/backend/alembic/versions/20260812_01_complete_workflow.py +++ b/backend/alembic/versions/20260812_01_complete_workflow.py @@ -22,20 +22,43 @@ def upgrade() -> None: op.execute("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'STAFF'") photo_columns = {column["name"] for column in inspector.get_columns("photos")} if "thumbnail_storage_key" not in photo_columns: - op.add_column("photos", sa.Column("thumbnail_storage_key", sa.String(length=500), nullable=True)) + op.add_column( + "photos", + sa.Column("thumbnail_storage_key", sa.String(length=500), nullable=True), + ) if "thumbnail_size_bytes" not in photo_columns: - op.add_column("photos", sa.Column("thumbnail_size_bytes", sa.BigInteger(), nullable=False, server_default="0")) + op.add_column( + "photos", + sa.Column( + "thumbnail_size_bytes", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) enrollment_columns = {column["name"] for column in inspector.get_columns("face_enrollments")} if "size_bytes" not in enrollment_columns: - op.add_column("face_enrollments", sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0")) + op.add_column( + "face_enrollments", + sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"), + ) email_columns = {column["name"] for column in inspector.get_columns("email_outbox")} if "attempts" not in email_columns: - op.add_column("email_outbox", sa.Column("attempts", sa.Integer(), nullable=False, server_default="0")) + op.add_column( + "email_outbox", + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + ) if "next_attempt_at" not in email_columns: - op.add_column("email_outbox", sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column( + "email_outbox", + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + ) op.create_index("ix_email_outbox_next_attempt_at", "email_outbox", ["next_attempt_at"]) if "last_attempt_at" not in email_columns: - op.add_column("email_outbox", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column( + "email_outbox", + sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), + ) def downgrade() -> None: diff --git a/backend/alembic/versions/20260812_02_email_delivery_link.py b/backend/alembic/versions/20260812_02_email_delivery_link.py index a2091eb..e269dc2 100644 --- a/backend/alembic/versions/20260812_02_email_delivery_link.py +++ b/backend/alembic/versions/20260812_02_email_delivery_link.py @@ -14,8 +14,18 @@ def upgrade() -> None: inspector = inspect(op.get_bind()) columns = {column["name"] for column in inspector.get_columns("email_outbox")} if "delivery_id" not in columns: - op.add_column("email_outbox", sa.Column("delivery_id", sa.String(length=36), nullable=True)) - op.create_foreign_key("fk_email_outbox_delivery_id", "email_outbox", "deliveries", ["delivery_id"], ["id"], ondelete="SET NULL") + op.add_column( + "email_outbox", + sa.Column("delivery_id", sa.String(length=36), nullable=True), + ) + op.create_foreign_key( + "fk_email_outbox_delivery_id", + "email_outbox", + "deliveries", + ["delivery_id"], + ["id"], + ondelete="SET NULL", + ) op.create_index("ix_email_outbox_delivery_id", "email_outbox", ["delivery_id"]) diff --git a/backend/alembic/versions/20260813_03_v2_foundation.py b/backend/alembic/versions/20260813_03_v2_foundation.py index 4847371..3a6207f 100644 --- a/backend/alembic/versions/20260813_03_v2_foundation.py +++ b/backend/alembic/versions/20260813_03_v2_foundation.py @@ -21,49 +21,238 @@ def _add(table: str, name: str, column: sa.Column) -> None: def upgrade() -> None: op.execute("CREATE EXTENSION IF NOT EXISTS vector") - _add("users", "updated_at", sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())) - - _add("events", "starts_at", sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True)) - _add("events", "ends_at", sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True)) - _add("events", "enrollment_opens_at", sa.Column("enrollment_opens_at", sa.DateTime(timezone=True), nullable=True)) - _add("events", "enrollment_closes_at", sa.Column("enrollment_closes_at", sa.DateTime(timezone=True), nullable=True)) - _add("events", "gallery_expires_at", sa.Column("gallery_expires_at", sa.DateTime(timezone=True), nullable=True)) - _add("events", "created_by", sa.Column("created_by", sa.String(length=36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True)) - - _add("face_enrollments", "organization_id", sa.Column("organization_id", sa.String(length=36), sa.ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True)) - _add("face_enrollments", "embedding_vector", sa.Column("embedding_vector", Vector(512), nullable=True)) - _add("face_enrollments", "event_id", sa.Column("event_id", sa.String(length=36), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=True)) - _add("face_enrollments", "status", sa.Column("status", sa.String(length=24), nullable=False, server_default="valid")) - _add("face_enrollments", "model_name", sa.Column("model_name", sa.String(length=120), nullable=False, server_default="adaface-ir101-ms1mv2")) - _add("face_enrollments", "model_version", sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1")) - _add("face_enrollments", "embedding_dimension", sa.Column("embedding_dimension", sa.Integer(), nullable=False, server_default="512")) - _add("face_enrollments", "quality_score", sa.Column("quality_score", sa.Float(), nullable=True)) - _add("face_enrollments", "expires_at", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True)) - _add("face_enrollments", "deleted_at", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) - - _add("face_detections", "face_index", sa.Column("face_index", sa.Integer(), nullable=False, server_default="0")) - _add("face_detections", "embedding_vector", sa.Column("embedding_vector", Vector(512), nullable=True)) + _add( + "users", + "updated_at", + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + _add( + "events", + "starts_at", + sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True), + ) + _add( + "events", + "ends_at", + sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True), + ) + _add( + "events", + "enrollment_opens_at", + sa.Column("enrollment_opens_at", sa.DateTime(timezone=True), nullable=True), + ) + _add( + "events", + "enrollment_closes_at", + sa.Column("enrollment_closes_at", sa.DateTime(timezone=True), nullable=True), + ) + _add( + "events", + "gallery_expires_at", + sa.Column("gallery_expires_at", sa.DateTime(timezone=True), nullable=True), + ) + _add( + "events", + "created_by", + sa.Column( + "created_by", + sa.String(length=36), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + _add( + "face_enrollments", + "organization_id", + sa.Column( + "organization_id", + sa.String(length=36), + sa.ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=True, + ), + ) + _add( + "face_enrollments", + "embedding_vector", + sa.Column("embedding_vector", Vector(512), nullable=True), + ) + _add( + "face_enrollments", + "event_id", + sa.Column( + "event_id", + sa.String(length=36), + sa.ForeignKey("events.id", ondelete="CASCADE"), + nullable=True, + ), + ) + _add( + "face_enrollments", + "status", + sa.Column("status", sa.String(length=24), nullable=False, server_default="valid"), + ) + _add( + "face_enrollments", + "model_name", + sa.Column( + "model_name", + sa.String(length=120), + nullable=False, + server_default="adaface-ir101-ms1mv2", + ), + ) + _add( + "face_enrollments", + "model_version", + sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1"), + ) + _add( + "face_enrollments", + "embedding_dimension", + sa.Column("embedding_dimension", sa.Integer(), nullable=False, server_default="512"), + ) + _add( + "face_enrollments", + "quality_score", + sa.Column("quality_score", sa.Float(), nullable=True), + ) + _add( + "face_enrollments", + "expires_at", + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + ) + _add( + "face_enrollments", + "deleted_at", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + + _add( + "face_detections", + "face_index", + sa.Column("face_index", sa.Integer(), nullable=False, server_default="0"), + ) + _add( + "face_detections", + "embedding_vector", + sa.Column("embedding_vector", Vector(512), nullable=True), + ) _add("face_detections", "landmarks", sa.Column("landmarks", sa.JSON(), nullable=True)) - _add("face_detections", "face_width", sa.Column("face_width", sa.Integer(), nullable=True)) - _add("face_detections", "face_height", sa.Column("face_height", sa.Integer(), nullable=True)) - _add("face_detections", "quality_class", sa.Column("quality_class", sa.String(length=24), nullable=False, server_default="GOOD")) - _add("face_detections", "model_name", sa.Column("model_name", sa.String(length=120), nullable=False, server_default="retinaface-r50")) - _add("face_detections", "model_version", sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1")) + _add( + "face_detections", + "face_width", + sa.Column("face_width", sa.Integer(), nullable=True), + ) + _add( + "face_detections", + "face_height", + sa.Column("face_height", sa.Integer(), nullable=True), + ) + _add( + "face_detections", + "quality_class", + sa.Column("quality_class", sa.String(length=24), nullable=False, server_default="GOOD"), + ) + _add( + "face_detections", + "model_name", + sa.Column( + "model_name", + sa.String(length=120), + nullable=False, + server_default="retinaface-r50", + ), + ) + _add( + "face_detections", + "model_version", + sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1"), + ) - _add("face_matches", "second_best_score", sa.Column("second_best_score", sa.Float(), nullable=True)) + _add( + "face_matches", + "second_best_score", + sa.Column("second_best_score", sa.Float(), nullable=True), + ) _add("face_matches", "margin", sa.Column("margin", sa.Float(), nullable=True)) - _add("face_matches", "decision_source", sa.Column("decision_source", sa.String(length=24), nullable=False, server_default="AUTO")) - _add("face_matches", "model_name", sa.Column("model_name", sa.String(length=120), nullable=False, server_default="adaface-ir101-ms1mv2")) - _add("face_matches", "model_version", sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1")) - _add("face_matches", "threshold_profile_version", sa.Column("threshold_profile_version", sa.String(length=80), nullable=False, server_default="default-v1")) - - _add("processing_jobs", "attempt", sa.Column("attempt", sa.Integer(), nullable=False, server_default="0")) - _add("processing_jobs", "max_attempts", sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5")) - _add("processing_jobs", "progress_current", sa.Column("progress_current", sa.Integer(), nullable=False, server_default="0")) - _add("processing_jobs", "progress_total", sa.Column("progress_total", sa.Integer(), nullable=False, server_default="100")) - _add("processing_jobs", "correlation_id", sa.Column("correlation_id", sa.String(length=36), nullable=True)) - _add("processing_jobs", "next_attempt_at", sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True)) - _add("processing_jobs", "heartbeat_at", sa.Column("heartbeat_at", sa.DateTime(timezone=True), nullable=True)) + _add( + "face_matches", + "decision_source", + sa.Column( + "decision_source", + sa.String(length=24), + nullable=False, + server_default="AUTO", + ), + ) + _add( + "face_matches", + "model_name", + sa.Column( + "model_name", + sa.String(length=120), + nullable=False, + server_default="adaface-ir101-ms1mv2", + ), + ) + _add( + "face_matches", + "model_version", + sa.Column("model_version", sa.String(length=80), nullable=False, server_default="1"), + ) + _add( + "face_matches", + "threshold_profile_version", + sa.Column( + "threshold_profile_version", + sa.String(length=80), + nullable=False, + server_default="default-v1", + ), + ) + + _add( + "processing_jobs", + "attempt", + sa.Column("attempt", sa.Integer(), nullable=False, server_default="0"), + ) + _add( + "processing_jobs", + "max_attempts", + sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5"), + ) + _add( + "processing_jobs", + "progress_current", + sa.Column("progress_current", sa.Integer(), nullable=False, server_default="0"), + ) + _add( + "processing_jobs", + "progress_total", + sa.Column("progress_total", sa.Integer(), nullable=False, server_default="100"), + ) + _add( + "processing_jobs", + "correlation_id", + sa.Column("correlation_id", sa.String(length=36), nullable=True), + ) + _add( + "processing_jobs", + "next_attempt_at", + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + ) + _add( + "processing_jobs", + "heartbeat_at", + sa.Column("heartbeat_at", sa.DateTime(timezone=True), nullable=True), + ) # New V2 tables are declared centrally in models.py; create_all is safe and # idempotent here and keeps local PostgreSQL and production migrations aligned. @@ -72,7 +261,11 @@ def upgrade() -> None: # create_all creates upload_batches on fresh databases; this guard supports # an interrupted/partially-applied V2 migration as well. if "upload_batches" in inspect(op.get_bind()).get_table_names(): - _add("upload_batches", "manifest", sa.Column("manifest", sa.JSON(), nullable=True)) + _add( + "upload_batches", + "manifest", + sa.Column("manifest", sa.JSON(), nullable=True), + ) op.execute(""" UPDATE face_enrollments AS enrollment diff --git a/backend/alembic/versions/20260813_05_spec_verification.py b/backend/alembic/versions/20260813_05_spec_verification.py new file mode 100644 index 0000000..d5904fd --- /dev/null +++ b/backend/alembic/versions/20260813_05_spec_verification.py @@ -0,0 +1,32 @@ +"""Add durable state for direct enrollment uploads. + +Revision ID: 20260813_05 +Revises: 20260813_04 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "20260813_05" +down_revision = "20260813_04" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + columns = {column["name"] for column in inspector.get_columns("participant_enrollment_tokens")} + additions = { + "pending_storage_key": sa.Column("pending_storage_key", sa.String(length=500), nullable=True), + "pending_content_type": sa.Column("pending_content_type", sa.String(length=120), nullable=True), + "pending_size_bytes": sa.Column("pending_size_bytes", sa.BigInteger(), nullable=True), + "pending_sha256": sa.Column("pending_sha256", sa.String(length=64), nullable=True), + } + for name, column in additions.items(): + if name not in columns: + op.add_column("participant_enrollment_tokens", column) + + +def downgrade() -> None: + # Security and biometric lifecycle migrations are intentionally forward-only. + pass diff --git a/backend/app/auth.py b/backend/app/auth.py index 5ac5796..463d950 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -43,7 +43,12 @@ def verify_password(password: str, encoded: str | None) -> bool: _, n, r, p, salt, digest = encoded.split("$", 5) candidate = hashlib.scrypt( - password.encode(), salt=base64.b64decode(salt), n=int(n), r=int(r), p=int(p), dklen=64 + password.encode(), + salt=base64.b64decode(salt), + n=int(n), + r=int(r), + p=int(p), + dklen=64, ) return hmac.compare_digest(candidate, base64.b64decode(digest)) except (ValueError, TypeError): @@ -68,7 +73,12 @@ def access_token(user: User, session_id: str | None = None) -> dict: settings.jwt_secret, algorithm="HS256", ) - return {"token": token, "access_token": token, "expiresAt": expires.isoformat(), "expires_in": settings.access_token_minutes * 60} + return { + "token": token, + "access_token": token, + "expiresAt": expires.isoformat(), + "expires_in": settings.access_token_minutes * 60, + } def create_refresh_session(db: Session, user: User, request: Request) -> tuple[str, RefreshSession]: @@ -86,7 +96,9 @@ def create_refresh_session(db: Session, user: User, request: Request) -> tuple[s def rotate_refresh_session(db: Session, raw_token: str, request: Request) -> tuple[User, str, RefreshSession]: - session = db.scalar(select(RefreshSession).where(RefreshSession.refresh_token_hash == hash_token(raw_token)).with_for_update()) + session = db.scalar( + select(RefreshSession).where(RefreshSession.refresh_token_hash == hash_token(raw_token)).with_for_update() + ) now = utcnow() if not session or session.revoked_at or session.expires_at <= now: raise HTTPException(status_code=401, detail="Refresh session is invalid or expired") @@ -128,7 +140,10 @@ def current_user( audience=settings.jwt_audience, ) except jwt.PyJWTError as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session") from exc + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired session", + ) from exc user = db.get(User, payload.get("sub")) if not user or user.status != "active": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is not active") @@ -136,29 +151,55 @@ def current_user( if session_id: session = db.get(RefreshSession, session_id) if not session or session.revoked_at or session.expires_at <= utcnow(): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Session has been revoked", + ) return user def require_super_admin(user: User = Depends(current_user)) -> User: if user.role != UserRole.SUPER_ADMIN: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Super Admin permission required") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Super Admin permission required", + ) return user def require_org_admin(user: User = Depends(current_user)) -> User: if user.role != UserRole.ORG_ADMIN or not user.organization_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Organization Admin permission required") - if not user.organization or user.organization.status != "active" or (user.organization.expires_at and user.organization.expires_at < date.today()): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Organization is suspended or expired") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Organization Admin permission required", + ) + if ( + not user.organization + or user.organization.status != "active" + or (user.organization.expires_at and user.organization.expires_at < date.today()) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Organization is suspended or expired", + ) return user def require_org_member(user: User = Depends(current_user)) -> User: if user.role not in {UserRole.ORG_ADMIN, UserRole.STAFF} or not user.organization_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Organization membership required") - if not user.organization or user.organization.status != "active" or (user.organization.expires_at and user.organization.expires_at < date.today()): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Organization is suspended or expired") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Organization membership required", + ) + if ( + not user.organization + or user.organization.status != "active" + or (user.organization.expires_at and user.organization.expires_at < date.today()) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Organization is suspended or expired", + ) return user @@ -170,7 +211,10 @@ def check_login_rate_limit(request: Request, email: str) -> None: if attempts == 1: redis.expire(key, 60) if attempts > 10: - raise HTTPException(status_code=429, detail="Too many login attempts. Try again in one minute.") + raise HTTPException( + status_code=429, + detail="Too many login attempts. Try again in one minute.", + ) except RedisError: # Database auth remains available during a cache outage; health exposes the failure. return diff --git a/backend/app/config.py b/backend/app/config.py index 8c850e5..f0cd34a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,9 +9,7 @@ @dataclass(frozen=True) class Settings: - database_url: str = os.getenv( - "DATABASE_URL", "postgresql+psycopg://fdx:fdx@127.0.0.1:5432/fdx" - ) + database_url: str = os.getenv("DATABASE_URL", "postgresql+psycopg://fdx:fdx@127.0.0.1:5432/fdx") redis_url: str = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0") kafka_bootstrap_servers: str = os.getenv("KAFKA_BOOTSTRAP_SERVERS", "127.0.0.1:9092") kafka_topic: str = os.getenv("KAFKA_TOPIC", "fdx.photo.processing") @@ -44,14 +42,29 @@ class Settings: consent_policy_version: str = os.getenv("CONSENT_POLICY_VERSION", "2026-08-13") upload_reservation_minutes: int = int(os.getenv("UPLOAD_RESERVATION_MINUTES", "60")) max_upload_bytes: int = int(os.getenv("MAX_UPLOAD_BYTES", str(100 * 1024**3))) + max_media_file_bytes: int = int(os.getenv("MAX_MEDIA_FILE_BYTES", str(100 * 1024**2))) + max_enrollment_bytes: int = int(os.getenv("MAX_ENROLLMENT_BYTES", str(15 * 1024**2))) + max_image_pixels: int = int(os.getenv("MAX_IMAGE_PIXELS", "100000000")) + multipart_threshold_bytes: int = int(os.getenv("MULTIPART_THRESHOLD_BYTES", str(20 * 1024**2))) + multipart_part_bytes: int = int(os.getenv("MULTIPART_PART_BYTES", str(8 * 1024**2))) match_auto_threshold: float = float(os.getenv("MATCH_AUTO_THRESHOLD", "0.85")) match_review_threshold: float = float(os.getenv("MATCH_REVIEW_THRESHOLD", "0.65")) match_runner_up_margin: float = float(os.getenv("MATCH_RUNNER_UP_MARGIN", "0.08")) + minimum_face_size: int = int(os.getenv("MINIMUM_FACE_SIZE", "40")) + low_resolution_face_size: int = int(os.getenv("LOW_RESOLUTION_FACE_SIZE", "80")) + minimum_detector_confidence: float = float(os.getenv("MINIMUM_DETECTOR_CONFIDENCE", "0.60")) + low_resolution_threshold_boost: float = float(os.getenv("LOW_RESOLUTION_THRESHOLD_BOOST", "0.05")) threshold_profile_version: str = os.getenv("THRESHOLD_PROFILE_VERSION", "default-v1") detector_model_version: str = os.getenv("FDX_DETECTOR_MODEL_VERSION", "retinaface-r50-v1") embedder_model_version: str = os.getenv("FDX_EMBEDDER_MODEL_VERSION", "adaface-ir101-ms1mv2-v1") - detector_model_sha256: str = os.getenv("FDX_DETECTOR_MODEL_SHA256", "a607583ad9913b3a54f1b750752ae3f451fe324777df5542921e4b0b8e596a87") - embedder_model_sha256: str = os.getenv("FDX_EMBEDDER_MODEL_SHA256", "c594643ebe011c2534dd870d4abb0635ec27ce58e50b53f40b8d888a395e575e") + detector_model_sha256: str = os.getenv( + "FDX_DETECTOR_MODEL_SHA256", + "a607583ad9913b3a54f1b750752ae3f451fe324777df5542921e4b0b8e596a87", + ) + embedder_model_sha256: str = os.getenv( + "FDX_EMBEDDER_MODEL_SHA256", + "c594643ebe011c2534dd870d4abb0635ec27ce58e50b53f40b8d888a395e575e", + ) resend_webhook_secret: str = os.getenv("RESEND_WEBHOOK_SECRET", "") email_webhook_secret: str = os.getenv("EMAIL_WEBHOOK_SECRET", "") diff --git a/backend/app/integrations.py b/backend/app/integrations.py index 6d48843..f5377c9 100644 --- a/backend/app/integrations.py +++ b/backend/app/integrations.py @@ -28,7 +28,12 @@ def __init__(self): def put(self, key: str, content: bytes, content_type: str) -> None: if self.s3: - self.s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=content, ContentType=content_type) + self.s3.put_object( + Bucket=settings.s3_bucket, + Key=key, + Body=content, + ContentType=content_type, + ) return target = (self.root / key).resolve() if self.root not in target.parents: @@ -58,10 +63,72 @@ def presign_put(self, key: str, content_type: str, expires: int = 900) -> str | return None return self.s3.generate_presigned_url( "put_object", - Params={"Bucket": settings.s3_bucket, "Key": key, "ContentType": content_type}, + Params={ + "Bucket": settings.s3_bucket, + "Key": key, + "ContentType": content_type, + }, ExpiresIn=expires, ) + def create_multipart_upload( + self, + key: str, + content_type: str, + size_bytes: int, + part_size: int, + expires: int = 900, + ) -> dict | None: + if not self.s3: + return None + upload = self.s3.create_multipart_upload( + Bucket=settings.s3_bucket, + Key=key, + ContentType=content_type, + ) + upload_id = upload["UploadId"] + part_count = (size_bytes + part_size - 1) // part_size + parts = [ + { + "part_number": part_number, + "upload_url": self.s3.generate_presigned_url( + "upload_part", + Params={ + "Bucket": settings.s3_bucket, + "Key": key, + "UploadId": upload_id, + "PartNumber": part_number, + }, + ExpiresIn=expires, + ), + } + for part_number in range(1, part_count + 1) + ] + return {"upload_id": upload_id, "part_size": part_size, "parts": parts} + + def complete_multipart_upload(self, key: str, upload_id: str, parts: list[dict]) -> None: + if not self.s3: + raise RuntimeError("Multipart uploads require S3 storage") + self.s3.complete_multipart_upload( + Bucket=settings.s3_bucket, + Key=key, + UploadId=upload_id, + MultipartUpload={ + "Parts": [ + {"PartNumber": part["part_number"], "ETag": part["etag"]} + for part in sorted(parts, key=lambda item: item["part_number"]) + ] + }, + ) + + def abort_multipart_upload(self, key: str, upload_id: str) -> None: + if self.s3: + self.s3.abort_multipart_upload( + Bucket=settings.s3_bucket, + Key=key, + UploadId=upload_id, + ) + def presign_get(self, key: str, expires: int = 600, filename: str | None = None) -> str | None: if not self.s3: return None @@ -119,7 +186,14 @@ def cache_delete(*keys: str) -> None: pass -def queue_email(db: Session, organization_id: str | None, recipient: str, subject: str, html: str, delivery_id: str | None = None) -> EmailOutbox: +def queue_email( + db: Session, + organization_id: str | None, + recipient: str, + subject: str, + html: str, + delivery_id: str | None = None, +) -> EmailOutbox: item = EmailOutbox( organization_id=organization_id, delivery_id=delivery_id, @@ -143,7 +217,12 @@ def dispatch_email(db: Session, item: EmailOutbox) -> None: response = httpx.post( "https://api.resend.com/emails", headers={"Authorization": f"Bearer {settings.resend_api_key}"}, - json={"from": settings.email_from, "to": [item.recipient], "subject": item.subject, "html": item.html}, + json={ + "from": settings.email_from, + "to": [item.recipient], + "subject": item.subject, + "html": item.html, + }, timeout=20, ) response.raise_for_status() @@ -152,7 +231,12 @@ def dispatch_email(db: Session, item: EmailOutbox) -> None: response = boto3.client("sesv2", region_name=settings.s3_region).send_email( FromEmailAddress=settings.email_from, Destination={"ToAddresses": [item.recipient]}, - Content={"Simple": {"Subject": {"Data": item.subject}, "Body": {"Html": {"Data": item.html}}}}, + Content={ + "Simple": { + "Subject": {"Data": item.subject}, + "Body": {"Html": {"Data": item.html}}, + } + }, ) item.provider_id = response.get("MessageId") else: @@ -186,7 +270,12 @@ def dispatch_email(db: Session, item: EmailOutbox) -> None: def ml_embedding(content: bytes, filename: str, content_type: str) -> dict: response = httpx.post( f"{settings.ml_service_url}/find_faces", - params={"face_plugins": "calculator", "det_prob_threshold": 0.8, "limit": 1, "input_mode": "cropped"}, + params={ + "face_plugins": "calculator", + "det_prob_threshold": 0.8, + "limit": 1, + "input_mode": "cropped", + }, files={"file": (filename, content, content_type)}, timeout=300, ) @@ -217,13 +306,27 @@ def dependency_health() -> list[dict]: email_detail = f"AWS SES configured in {settings.s3_region}" if configured else "AWS SES sender is incomplete" else: configured = settings.environment != "production" - email_detail = "Persistent development outbox" if configured else "Production cannot use the local outbox provider" - services.append({"name": "Email", "detail": email_detail, "status": "healthy" if configured else "degraded"}) + email_detail = ( + "Persistent development outbox" if configured else "Production cannot use the local outbox provider" + ) + services.append( + { + "name": "Email", + "detail": email_detail, + "status": "healthy" if configured else "degraded", + } + ) redis = None try: redis = Redis.from_url(settings.redis_url, socket_connect_timeout=1, decode_responses=True) redis.ping() - services.append({"name": "Redis", "detail": "Cache and rate limiting available", "status": "healthy"}) + services.append( + { + "name": "Redis", + "detail": "Cache and rate limiting available", + "status": "healthy", + } + ) except RedisError as exc: services.append({"name": "Redis", "detail": str(exc), "status": "degraded"}) if redis: @@ -242,10 +345,21 @@ def dependency_health() -> list[dict]: except Exception as exc: dependencies.append({"name": "ML Workers", "detail": str(exc), "status": "degraded"}) try: - admin = KafkaAdminClient(bootstrap_servers=settings.kafka_bootstrap_servers.split(","), security_protocol=settings.kafka_security_protocol, request_timeout_ms=2000, api_version_auto_timeout_ms=2000) + admin = KafkaAdminClient( + bootstrap_servers=settings.kafka_bootstrap_servers.split(","), + security_protocol=settings.kafka_security_protocol, + request_timeout_ms=2000, + api_version_auto_timeout_ms=2000, + ) topics = admin.list_topics() admin.close() - dependencies.append({"name": "Kafka", "detail": f"{len(topics)} topics available", "status": "healthy"}) + dependencies.append( + { + "name": "Kafka", + "detail": f"{len(topics)} topics available", + "status": "healthy", + } + ) except Exception as exc: dependencies.append({"name": "Kafka", "detail": str(exc), "status": "degraded"}) if redis: diff --git a/backend/app/main.py b/backend/app/main.py index 4a407e1..a32a6b9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -78,7 +78,14 @@ GB = 1024**3 METRICS_LOCK = threading.Lock() -REQUEST_METRICS = {"requests": 0, "latency_seconds": 0.0, "responses_4xx": 0, "responses_5xx": 0, "rate_limited": 0, "auth_failures": 0} +REQUEST_METRICS = { + "requests": 0, + "latency_seconds": 0.0, + "responses_4xx": 0, + "responses_5xx": 0, + "rate_limited": 0, + "auth_failures": 0, +} # Reuse Uvicorn's configured service logger so JSON request records are emitted # consistently in containers without installing a second handler. logger = logging.getLogger("uvicorn.error") @@ -144,16 +151,38 @@ class SettingsInput(BaseModel): phone: str | None = None -def audit(db: Session, user: User | None, action: str, details: str, level: str = "info", organization_id: str | None = None) -> None: - db.add(AuditLog(organization_id=organization_id if organization_id is not None else user.organization_id if user else None, actor_user_id=user.id if user else None, actor=user.email if user else "system", action=action, details=details, level=level)) +def audit( + db: Session, + user: User | None, + action: str, + details: str, + level: str = "info", + organization_id: str | None = None, +) -> None: + db.add( + AuditLog( + organization_id=organization_id if organization_id is not None else user.organization_id if user else None, + actor_user_id=user.id if user else None, + actor=user.email if user else "system", + action=action, + details=details, + level=level, + ) + ) def bootstrap() -> None: if settings.environment == "production": - insecure_jwt_values = {"change-this-development-secret", "replace-this-before-production"} + insecure_jwt_values = { + "change-this-development-secret", + "replace-this-before-production", + } if len(settings.jwt_secret) < 32 or settings.jwt_secret in insecure_jwt_values: raise RuntimeError("Production requires a unique JWT_SECRET of at least 32 characters") - if settings.super_admin_password in {"SuperAdmin@123", "replace-with-a-strong-bootstrap-password"}: + if settings.super_admin_password in { + "SuperAdmin@123", + "replace-with-a-strong-bootstrap-password", + }: raise RuntimeError("Production requires a unique FDX_SUPER_ADMIN_PASSWORD") # Production schema ownership belongs exclusively to reviewed Alembic # migrations. Local development keeps the convenience bootstrap. @@ -162,22 +191,39 @@ def bootstrap() -> None: with SessionLocal() as db: existing = find_user_by_email(db, settings.super_admin_email) if not existing: - db.add(User(name="FDX Super Admin", email=settings.super_admin_email.lower(), password_hash=hash_password(settings.super_admin_password), role=UserRole.SUPER_ADMIN, status="active")) - db.add(AuditLog(actor="system", action="Platform initialized", details="Initial Super Admin account created", level="info")) + db.add( + User( + name="FDX Super Admin", + email=settings.super_admin_email.lower(), + password_hash=hash_password(settings.super_admin_password), + role=UserRole.SUPER_ADMIN, + status="active", + ) + ) + db.add( + AuditLog( + actor="system", + action="Platform initialized", + details="Initial Super Admin account created", + level="info", + ) + ) db.commit() if not db.scalar(select(ModelRegistry).where(ModelRegistry.active.is_(True))): - db.add(ModelRegistry( - detector_name="retinaface-r50", - detector_version=settings.detector_model_version, - detector_sha256=settings.detector_model_sha256, - embedder_name="adaface-ir101-ms1mv2", - embedder_version=settings.embedder_model_version, - embedder_sha256=settings.embedder_model_sha256, - embedding_dimension=512, - metric="cosine", - threshold_profile_version=settings.threshold_profile_version, - active=True, - )) + db.add( + ModelRegistry( + detector_name="retinaface-r50", + detector_version=settings.detector_model_version, + detector_sha256=settings.detector_model_sha256, + embedder_name="adaface-ir101-ms1mv2", + embedder_version=settings.embedder_model_version, + embedder_sha256=settings.embedder_model_sha256, + embedding_dimension=512, + metric="cosine", + threshold_profile_version=settings.threshold_profile_version, + active=True, + ) + ) db.commit() @@ -188,7 +234,17 @@ async def lifespan(_: FastAPI): app = FastAPI(title="FDX Platform API", version="1.0.0", lifespan=lifespan) -app.add_middleware(CORSMiddleware, allow_origins=[settings.frontend_url, "http://127.0.0.1:5173", "http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + settings.frontend_url, + "http://127.0.0.1:5173", + "http://localhost:5173", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) @app.middleware("http") @@ -210,23 +266,30 @@ async def request_context(request: Request, call_next): if response.status_code == 401 and request.url.path.startswith("/api"): REQUEST_METRICS["auth_failures"] += 1 route = request.scope.get("route") - logger.info(json.dumps({ - "timestamp": utcnow().isoformat(), - "level": "INFO", - "service": "fdx-api", - "request_id": request.state.request_id, - "correlation_id": request.state.correlation_id, - "method": request.method, - "path": getattr(route, "path", "/redacted"), - "status": response.status_code, - "duration_ms": round(elapsed * 1000, 2), - }, separators=(",", ":"))) + logger.info( + json.dumps( + { + "timestamp": utcnow().isoformat(), + "level": "INFO", + "service": "fdx-api", + "request_id": request.state.request_id, + "correlation_id": request.state.correlation_id, + "method": request.method, + "path": getattr(route, "path", "/redacted"), + "status": response.status_code, + "duration_ms": round(elapsed * 1000, 2), + }, + separators=(",", ":"), + ) + ) response.headers["X-Request-ID"] = request.state.request_id response.headers["X-Correlation-ID"] = request.state.correlation_id response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Permissions-Policy"] = "camera=(self)" - response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'" + ) response.headers["X-Response-Time-Ms"] = f"{elapsed * 1000:.2f}" if settings.environment == "production": response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" @@ -236,7 +299,11 @@ async def request_context(request: Request, call_next): @app.exception_handler(HTTPException) async def http_error(request: Request, exc: HTTPException): if not request.url.path.startswith("/api/v2"): - return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers) + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=exc.headers, + ) code = { 400: "BAD_REQUEST", 401: "AUTHENTICATION_REQUIRED", @@ -250,7 +317,10 @@ async def http_error(request: Request, exc: HTTPException): }.get(exc.status_code, "REQUEST_FAILED") return JSONResponse( status_code=exc.status_code, - content={"error": {"code": code, "message": str(exc.detail), "details": {}}, "meta": {"request_id": request.state.request_id}}, + content={ + "error": {"code": code, "message": str(exc.detail), "details": {}}, + "meta": {"request_id": request.state.request_id}, + }, headers=exc.headers, ) @@ -261,7 +331,14 @@ async def validation_error(request: Request, exc: RequestValidationError): return JSONResponse(status_code=422, content={"detail": exc.errors()}) return JSONResponse( status_code=422, - content={"error": {"code": "VALIDATION_ERROR", "message": "Request validation failed.", "details": {"errors": exc.errors()}}, "meta": {"request_id": request.state.request_id}}, + content={ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "details": {"errors": exc.errors()}, + }, + "meta": {"request_id": request.state.request_id}, + }, ) @@ -272,7 +349,14 @@ async def unexpected_error(request: Request, exc: Exception): return JSONResponse(status_code=500, content={"detail": "Internal server error"}) return JSONResponse( status_code=500, - content={"error": {"code": "INTERNAL_ERROR", "message": "The request could not be completed.", "details": {}}, "meta": {"request_id": request.state.request_id}}, + content={ + "error": { + "code": "INTERNAL_ERROR", + "message": "The request could not be completed.", + "details": {}, + }, + "meta": {"request_id": request.state.request_id}, + }, ) @@ -281,12 +365,27 @@ async def unexpected_error(request: Request, exc: Exception): def health(db: Session = Depends(get_db)): db.execute(text("SELECT 1")) services = [ - {"name": "API Gateway", "detail": "NGINX routing and rate limits", "status": "healthy"}, - {"name": "FastAPI", "detail": "Application service available", "status": "healthy"}, - {"name": "PostgreSQL", "detail": "Primary database connected", "status": "healthy"}, + { + "name": "API Gateway", + "detail": "NGINX routing and rate limits", + "status": "healthy", + }, + { + "name": "FastAPI", + "detail": "Application service available", + "status": "healthy", + }, + { + "name": "PostgreSQL", + "detail": "Primary database connected", + "status": "healthy", + }, *dependency_health(), ] - return {"status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", "services": services} + return { + "status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", + "services": services, + } @app.get("/health/live") @@ -304,7 +403,10 @@ def health_ready(db: Session = Depends(get_db)): def health_dependencies(db: Session = Depends(get_db)): db.execute(text("SELECT 1")) services = [{"name": "PostgreSQL", "status": "healthy"}, *dependency_health()] - return {"status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", "services": services} + return { + "status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", + "services": services, + } @app.get("/metrics", include_in_schema=False) @@ -333,7 +435,10 @@ def login(payload: LoginInput, request: Request, db: Session = Depends(get_db)): raise HTTPException(status_code=401, detail="Invalid email or password") if user.status != "active": raise HTTPException(status_code=403, detail="Account is not active") - if user.organization and (user.organization.status != "active" or (user.organization.expires_at and user.organization.expires_at < date.today())): + if user.organization and ( + user.organization.status != "active" + or (user.organization.expires_at and user.organization.expires_at < date.today()) + ): raise HTTPException(status_code=403, detail="Organization access is suspended or expired") user.last_active_at = utcnow() audit(db, user, "Signed in", "JWT session issued") @@ -364,14 +469,39 @@ def accept_invitation(token: str, payload: PasswordInput, db: Session = Depends( @app.get("/api/admin/organizations") def list_organizations(_: User = Depends(require_super_admin), db: Session = Depends(get_db)): - return {"items": [organization_json(db, item) for item in db.scalars(select(Organization).order_by(Organization.created_at.desc())).all()]} + return { + "items": [ + organization_json(db, item) + for item in db.scalars(select(Organization).order_by(Organization.created_at.desc())).all() + ] + } @app.post("/api/admin/organizations", status_code=201) -def create_organization(payload: OrganizationInput, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): - organization = Organization(name=payload.name.strip(), type=payload.type, contact_name=payload.contactName.strip(), contact_email=str(payload.contactEmail).lower(), phone=payload.phone.strip(), storage_limit_bytes=int(payload.storageLimitGB * GB), retention_days=payload.retentionDays, expires_at=payload.expiry, status="active") +def create_organization( + payload: OrganizationInput, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): + organization = Organization( + name=payload.name.strip(), + type=payload.type, + contact_name=payload.contactName.strip(), + contact_email=str(payload.contactEmail).lower(), + phone=payload.phone.strip(), + storage_limit_bytes=int(payload.storageLimitGB * GB), + retention_days=payload.retentionDays, + expires_at=payload.expiry, + status="active", + ) db.add(organization) - audit(db, user, "Organization created", f"{organization.name} ({organization.type.value})", organization_id=organization.id) + audit( + db, + user, + "Organization created", + f"{organization.name} ({organization.type.value})", + organization_id=organization.id, + ) try: db.commit() except IntegrityError as exc: @@ -381,22 +511,42 @@ def create_organization(payload: OrganizationInput, user: User = Depends(require @app.patch("/api/admin/organizations/{organization_id}") -def update_organization(organization_id: str, payload: OrganizationPatch, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def update_organization( + organization_id: str, + payload: OrganizationPatch, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): organization = db.get(Organization, organization_id) if not organization: raise HTTPException(status_code=404, detail="Organization not found") values = payload.model_dump(exclude_unset=True) - mapping = {"contactName": "contact_name", "contactEmail": "contact_email", "retentionDays": "retention_days", "expiry": "expires_at", "storageLimitGB": "storage_limit_bytes"} + mapping = { + "contactName": "contact_name", + "contactEmail": "contact_email", + "retentionDays": "retention_days", + "expiry": "expires_at", + "storageLimitGB": "storage_limit_bytes", + } for key, value in values.items(): attribute = mapping.get(key, key) if key == "storageLimitGB": value = int(value * GB) if value < organization.storage_used_bytes: - raise HTTPException(status_code=422, detail="Quota cannot be below current storage usage") + raise HTTPException( + status_code=422, + detail="Quota cannot be below current storage usage", + ) if key == "status" and value not in {"active", "suspended"}: raise HTTPException(status_code=422, detail="Status must be active or suspended") setattr(organization, attribute, value) - audit(db, user, "Organization updated", f"{organization.name}: {', '.join(values)}", organization_id=organization.id) + audit( + db, + user, + "Organization updated", + f"{organization.name}: {', '.join(values)}", + organization_id=organization.id, + ) db.commit() cache_delete("fdx:admin:dashboard", f"fdx:org:{organization.id}:dashboard") return organization_json(db, organization) @@ -404,26 +554,66 @@ def update_organization(organization_id: str, payload: OrganizationPatch, user: @app.get("/api/admin/users") def list_users(_: User = Depends(require_super_admin), db: Session = Depends(get_db)): - users = db.scalars(select(User).where(User.role.in_([UserRole.ORG_ADMIN, UserRole.STAFF])).order_by(User.created_at.desc())).all() - return {"items": [{**user_json(item), "organization": item.organization.name if item.organization else None} for item in users]} + users = db.scalars( + select(User).where(User.role.in_([UserRole.ORG_ADMIN, UserRole.STAFF])).order_by(User.created_at.desc()) + ).all() + return { + "items": [ + { + **user_json(item), + "organization": item.organization.name if item.organization else None, + } + for item in users + ] + } @app.post("/api/admin/users", status_code=201) -def invite_user(payload: UserInviteInput, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def invite_user( + payload: UserInviteInput, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): organization = db.get(Organization, payload.organizationId) if not organization: raise HTTPException(status_code=404, detail="Organization not found") if find_user_by_email(db, str(payload.email)): raise HTTPException(status_code=409, detail="A user with this email already exists") raw_token, token_hash = new_opaque_token() - invited = User(organization_id=organization.id, name=payload.name.strip(), email=str(payload.email).lower(), role=UserRole.ORG_ADMIN, status="invited", invite_token_hash=token_hash, invite_expires_at=utcnow() + timedelta(days=7)) + invited = User( + organization_id=organization.id, + name=payload.name.strip(), + email=str(payload.email).lower(), + role=UserRole.ORG_ADMIN, + status="invited", + invite_token_hash=token_hash, + invite_expires_at=utcnow() + timedelta(days=7), + ) db.add(invited) db.flush() - db.add(UserInvitation(user_id=invited.id, token_hash=token_hash, expires_at=invited.invite_expires_at)) + db.add( + UserInvitation( + user_id=invited.id, + token_hash=token_hash, + expires_at=invited.invite_expires_at, + ) + ) invite_url = f"{settings.frontend_url}/accept-invite/{raw_token}" - email = queue_email(db, organization.id, invited.email, "You have been invited to FDX", f"

Hello {invited.name},

You have been invited to manage {organization.name} in FDX.

Set your password

This link expires in 7 days.

") + email = queue_email( + db, + organization.id, + invited.email, + "You have been invited to FDX", + f"

Hello {invited.name},

You have been invited to manage {organization.name} in FDX.

Set your password

This link expires in 7 days.

", + ) dispatch_email(db, email) - audit(db, user, "Organization Admin invited", f"{invited.email} → {organization.name}", organization_id=organization.id) + audit( + db, + user, + "Organization Admin invited", + f"{invited.email} → {organization.name}", + organization_id=organization.id, + ) db.commit() response = {**user_json(invited), "organization": organization.name} if settings.environment == "development": @@ -433,21 +623,47 @@ def invite_user(payload: UserInviteInput, user: User = Depends(require_super_adm @app.get("/api/organization/team") def organization_team(user: User = Depends(require_org_admin), db: Session = Depends(get_db)): - members = db.scalars(select(User).where(User.organization_id == user.organization_id).order_by(User.created_at.desc())).all() + members = db.scalars( + select(User).where(User.organization_id == user.organization_id).order_by(User.created_at.desc()) + ).all() return {"items": [user_json(member) for member in members]} @app.post("/api/organization/team", status_code=201) -def invite_staff(payload: StaffInviteInput, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def invite_staff( + payload: StaffInviteInput, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): if find_user_by_email(db, str(payload.email)): raise HTTPException(status_code=409, detail="A user with this email already exists") raw_token, token_hash = new_opaque_token() - invited = User(organization_id=user.organization_id, name=payload.name.strip(), email=str(payload.email).lower(), role=UserRole.STAFF, status="invited", invite_token_hash=token_hash, invite_expires_at=utcnow() + timedelta(days=7)) + invited = User( + organization_id=user.organization_id, + name=payload.name.strip(), + email=str(payload.email).lower(), + role=UserRole.STAFF, + status="invited", + invite_token_hash=token_hash, + invite_expires_at=utcnow() + timedelta(days=7), + ) db.add(invited) db.flush() - db.add(UserInvitation(user_id=invited.id, token_hash=token_hash, expires_at=invited.invite_expires_at)) + db.add( + UserInvitation( + user_id=invited.id, + token_hash=token_hash, + expires_at=invited.invite_expires_at, + ) + ) invite_url = f"{settings.frontend_url}/accept-invite/{raw_token}" - email = queue_email(db, user.organization_id, invited.email, f"You have been invited to {user.organization.name} on FDX", f"

Hello {invited.name},

You have been invited as event operations staff for {user.organization.name}.

Set your password

This link expires in 7 days.

") + email = queue_email( + db, + user.organization_id, + invited.email, + f"You have been invited to {user.organization.name} on FDX", + f"

Hello {invited.name},

You have been invited as event operations staff for {user.organization.name}.

Set your password

This link expires in 7 days.

", + ) dispatch_email(db, email) audit(db, user, "Staff invited", invited.email) db.commit() @@ -460,19 +676,57 @@ def invite_staff(payload: StaffInviteInput, user: User = Depends(require_org_adm @app.get("/api/admin/dashboard") def admin_dashboard(_: User = Depends(require_super_admin), db: Session = Depends(get_db)): organizations = db.scalars(select(Organization).order_by(Organization.storage_used_bytes.desc())).all() - jobs = db.scalar(select(func.count(ProcessingJob.id)).where(ProcessingJob.status.in_(["queued", "processing"]))) or 0 + jobs = ( + db.scalar(select(func.count(ProcessingJob.id)).where(ProcessingJob.status.in_(["queued", "processing"]))) or 0 + ) failed_jobs = db.scalar(select(func.count(ProcessingJob.id)).where(ProcessingJob.status == "failed")) or 0 email_total = db.scalar(select(func.count(EmailOutbox.id)).where(EmailOutbox.status == "sent")) or 0 photo_total = db.scalar(select(func.count(Photo.id))) or 0 services = health(db)["services"] - return {"stats": {"organizations": len(organizations), "activeOrganizations": sum(item.status == "active" for item in organizations), "organizationUsers": db.scalar(select(func.count(User.id)).where(User.role.in_([UserRole.ORG_ADMIN, UserRole.STAFF]))) or 0, "events": db.scalar(select(func.count(Event.id))) or 0, "photos": photo_total, "storageUsedGB": round(sum(item.storage_used_bytes for item in organizations) / GB, 2), "storageLimitGB": round(sum(item.storage_limit_bytes for item in organizations) / GB, 2), "processingJobs": jobs, "failedJobs": failed_jobs, "emailsSent": email_total, "expiringData": db.scalar(select(func.count(Event.id)).where(Event.expires_at <= date.today() + timedelta(days=30), Event.status != "expired")) or 0}, "organizations": [organization_json(db, item) for item in organizations], "services": services, "logs": log_items(db, None, 5)} + return { + "stats": { + "organizations": len(organizations), + "activeOrganizations": sum(item.status == "active" for item in organizations), + "organizationUsers": db.scalar( + select(func.count(User.id)).where(User.role.in_([UserRole.ORG_ADMIN, UserRole.STAFF])) + ) + or 0, + "events": db.scalar(select(func.count(Event.id))) or 0, + "photos": photo_total, + "storageUsedGB": round(sum(item.storage_used_bytes for item in organizations) / GB, 2), + "storageLimitGB": round(sum(item.storage_limit_bytes for item in organizations) / GB, 2), + "processingJobs": jobs, + "failedJobs": failed_jobs, + "emailsSent": email_total, + "expiringData": db.scalar( + select(func.count(Event.id)).where( + Event.expires_at <= date.today() + timedelta(days=30), + Event.status != "expired", + ) + ) + or 0, + }, + "organizations": [organization_json(db, item) for item in organizations], + "services": services, + "logs": log_items(db, None, 5), + } def log_items(db: Session, organization_id: str | None, limit: int = 200): statement = select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit) if organization_id: statement = statement.where(AuditLog.organization_id == organization_id) - return [{"id": item.id, "timestamp": iso(item.created_at), "actor": item.actor, "action": item.action, "details": item.details, "level": item.level} for item in db.scalars(statement).all()] + return [ + { + "id": item.id, + "timestamp": iso(item.created_at), + "actor": item.actor, + "action": item.action, + "details": item.details, + "level": item.level, + } + for item in db.scalars(statement).all() + ] @app.get("/api/admin/logs") @@ -483,13 +737,42 @@ def admin_logs(_: User = Depends(require_super_admin), db: Session = Depends(get @app.get("/api/admin/system") def admin_system(_: User = Depends(require_super_admin), db: Session = Depends(get_db)): service_result = health(db) - queue_counts = {status: count for status, count in db.execute(select(ProcessingJob.status, func.count(ProcessingJob.id)).group_by(ProcessingJob.status)).all()} + queue_counts = { + status: count + for status, count in db.execute( + select(ProcessingJob.status, func.count(ProcessingJob.id)).group_by(ProcessingJob.status) + ).all() + } recent_emails = db.scalars(select(EmailOutbox).order_by(EmailOutbox.created_at.desc()).limit(25)).all() - return {"status": service_result["status"], "services": service_result["services"], "queues": queue_counts, "emails": {status: count for status, count in db.execute(select(EmailOutbox.status, func.count(EmailOutbox.id)).group_by(EmailOutbox.status)).all()}, "recentEmails": [email_json(item) for item in recent_emails]} + return { + "status": service_result["status"], + "services": service_result["services"], + "queues": queue_counts, + "emails": { + status: count + for status, count in db.execute( + select(EmailOutbox.status, func.count(EmailOutbox.id)).group_by(EmailOutbox.status) + ).all() + }, + "recentEmails": [email_json(item) for item in recent_emails], + } def email_json(item: EmailOutbox) -> dict: - return {"id": item.id, "deliveryId": item.delivery_id, "recipient": item.recipient, "subject": item.subject, "status": item.status, "provider": item.provider, "providerId": item.provider_id, "attempts": item.attempts, "error": item.error, "createdAt": iso(item.created_at), "sentAt": iso(item.sent_at), "nextAttemptAt": iso(item.next_attempt_at)} + return { + "id": item.id, + "deliveryId": item.delivery_id, + "recipient": item.recipient, + "subject": item.subject, + "status": item.status, + "provider": item.provider, + "providerId": item.provider_id, + "attempts": item.attempts, + "error": item.error, + "createdAt": iso(item.created_at), + "sentAt": iso(item.sent_at), + "nextAttemptAt": iso(item.next_attempt_at), + } def retry_email_item(db: Session, item: EmailOutbox, user: User) -> dict: @@ -503,7 +786,11 @@ def retry_email_item(db: Session, item: EmailOutbox, user: User) -> dict: @app.post("/api/admin/emails/{email_id}/retry") -def admin_retry_email(email_id: str, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_retry_email( + email_id: str, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(EmailOutbox, email_id) if not item: raise HTTPException(status_code=404, detail="Email record not found") @@ -512,20 +799,36 @@ def admin_retry_email(email_id: str, user: User = Depends(require_super_admin), @app.get("/api/organization/emails") def organization_emails(user: User = Depends(require_org_admin), db: Session = Depends(get_db)): - items = db.scalars(select(EmailOutbox).where(EmailOutbox.organization_id == user.organization_id).order_by(EmailOutbox.created_at.desc()).limit(100)).all() + items = db.scalars( + select(EmailOutbox) + .where(EmailOutbox.organization_id == user.organization_id) + .order_by(EmailOutbox.created_at.desc()) + .limit(100) + ).all() return {"items": [email_json(item) for item in items]} @app.post("/api/organization/emails/{email_id}/retry") -def organization_retry_email(email_id: str, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): - item = db.scalar(select(EmailOutbox).where(EmailOutbox.id == email_id, EmailOutbox.organization_id == user.organization_id)) +def organization_retry_email( + email_id: str, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): + item = db.scalar( + select(EmailOutbox).where( + EmailOutbox.id == email_id, + EmailOutbox.organization_id == user.organization_id, + ) + ) if not item: raise HTTPException(status_code=404, detail="Email record not found") return retry_email_item(db, item, user) def org_events(db: Session, organization_id: str) -> list[Event]: - return db.scalars(select(Event).where(Event.organization_id == organization_id).order_by(Event.event_date.desc())).all() + return db.scalars( + select(Event).where(Event.organization_id == organization_id).order_by(Event.event_date.desc()) + ).all() @app.get("/api/organization/events") @@ -534,25 +837,75 @@ def list_events(user: User = Depends(require_org_member), db: Session = Depends( @app.get("/api/organization/events/{event_id}") -def event_detail(event_id: str, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def event_detail( + event_id: str, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): event = db.scalar(select(Event).where(Event.id == event_id, Event.organization_id == user.organization_id)) if not event: raise HTTPException(status_code=404, detail="Event not found") result = event_json(db, event) - result["participantsList"] = [participant_json(db, item) for item in db.scalars(select(Participant).where(Participant.event_id == event.id).order_by(Participant.created_at.desc()).limit(20)).all()] - result["photosList"] = [{"id": item.id, "filename": item.filename, "status": item.processing_status, "thumbnailUrl": f"/api/media/{item.id}/thumbnail" if item.thumbnail_storage_key else None, "uploadedAt": iso(item.uploaded_at)} for item in db.scalars(select(Photo).where(Photo.event_id == event.id).order_by(Photo.uploaded_at.desc()).limit(20)).all()] - result["matchCounts"] = {state: count for state, count in db.execute(select(FaceMatch.state, func.count(FaceMatch.id)).where(FaceMatch.event_id == event.id).group_by(FaceMatch.state)).all()} - result["deliveryCounts"] = {status: count for status, count in db.execute(select(Delivery.status, func.count(Delivery.id)).where(Delivery.event_id == event.id).group_by(Delivery.status)).all()} + result["participantsList"] = [ + participant_json(db, item) + for item in db.scalars( + select(Participant) + .where(Participant.event_id == event.id) + .order_by(Participant.created_at.desc()) + .limit(20) + ).all() + ] + result["photosList"] = [ + { + "id": item.id, + "filename": item.filename, + "status": item.processing_status, + "thumbnailUrl": f"/api/media/{item.id}/thumbnail" if item.thumbnail_storage_key else None, + "uploadedAt": iso(item.uploaded_at), + } + for item in db.scalars( + select(Photo).where(Photo.event_id == event.id).order_by(Photo.uploaded_at.desc()).limit(20) + ).all() + ] + result["matchCounts"] = { + state: count + for state, count in db.execute( + select(FaceMatch.state, func.count(FaceMatch.id)) + .where(FaceMatch.event_id == event.id) + .group_by(FaceMatch.state) + ).all() + } + result["deliveryCounts"] = { + status: count + for status, count in db.execute( + select(Delivery.status, func.count(Delivery.id)) + .where(Delivery.event_id == event.id) + .group_by(Delivery.status) + ).all() + } return result @app.post("/api/organization/events", status_code=201) -def create_event(payload: EventInput, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def create_event( + payload: EventInput, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): retention = payload.retentionDays or user.organization.retention_days expires = payload.expiresAt or payload.date + timedelta(days=retention) if expires < payload.date: raise HTTPException(status_code=422, detail="Data expiry must be after the event date") - event = Event(organization_id=user.organization_id, name=payload.name.strip(), description=payload.description.strip(), event_date=payload.date, location=payload.location.strip(), retention_days=retention, expires_at=expires, status="preparing") + event = Event( + organization_id=user.organization_id, + name=payload.name.strip(), + description=payload.description.strip(), + event_date=payload.date, + location=payload.location.strip(), + retention_days=retention, + expires_at=expires, + status="preparing", + ) db.add(event) audit(db, user, "Event created", event.name) try: @@ -564,13 +917,19 @@ def create_event(payload: EventInput, user: User = Depends(require_org_admin), d @app.delete("/api/organization/events/{event_id}", status_code=204) -def delete_event(event_id: str, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def delete_event( + event_id: str, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): event = db.scalar(select(Event).where(Event.id == event_id, Event.organization_id == user.organization_id)) if not event: raise HTTPException(status_code=404, detail="Event not found") photos = db.scalars(select(Photo).where(Photo.event_id == event.id)).all() enrollments = db.scalars(select(FaceEnrollment).join(Participant).where(Participant.event_id == event.id)).all() - released = sum(photo.size_bytes + photo.thumbnail_size_bytes for photo in photos) + sum(enrollment.size_bytes for enrollment in enrollments) + released = sum(photo.size_bytes + photo.thumbnail_size_bytes for photo in photos) + sum( + enrollment.size_bytes for enrollment in enrollments + ) for photo in photos: storage.delete(photo.storage_key) if photo.thumbnail_storage_key: @@ -580,14 +939,27 @@ def delete_event(event_id: str, user: User = Depends(require_org_admin), db: Ses event_name = event.name db.execute(delete(Event).where(Event.id == event.id)) user.organization.storage_used_bytes = max(0, user.organization.storage_used_bytes - released) - audit(db, user, "Event deleted", f"{event_name}: {len(photos)} photos and derived face data removed") + audit( + db, + user, + "Event deleted", + f"{event_name}: {len(photos)} photos and derived face data removed", + ) db.commit() return Response(status_code=204) @app.get("/api/organization/participants") -def list_participants(eventId: str | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): - statement = select(Participant).where(Participant.organization_id == user.organization_id).order_by(Participant.created_at.desc()) +def list_participants( + eventId: str | None = None, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): + statement = ( + select(Participant) + .where(Participant.organization_id == user.organization_id) + .order_by(Participant.created_at.desc()) + ) if eventId: statement = statement.where(Participant.event_id == eventId) return {"items": [participant_json(db, item) for item in db.scalars(statement).all()]} @@ -603,7 +975,13 @@ def participant_rows(content: bytes, filename: str) -> tuple[list[dict], int]: elif filename.lower().endswith((".xlsx", ".xlsm")): try: sheet = load_workbook(io.BytesIO(content), read_only=True, data_only=True).active - except (InvalidFileException, zipfile.BadZipFile, KeyError, OSError, ValueError) as exc: + except ( + InvalidFileException, + zipfile.BadZipFile, + KeyError, + OSError, + ValueError, + ) as exc: raise HTTPException(status_code=422, detail="The Excel workbook could not be read") from exc values = list(sheet.values) if not values: @@ -618,9 +996,20 @@ def participant_rows(content: bytes, filename: str) -> tuple[list[dict], int]: if sheet.nrows == 0: return [], 0 headers = [str(sheet.cell_value(0, column)).strip() for column in range(sheet.ncols)] - rows = [dict(zip(headers, [sheet.cell_value(row, column) for column in range(sheet.ncols)])) for row in range(1, sheet.nrows)] + rows = [ + dict( + zip( + headers, + [sheet.cell_value(row, column) for column in range(sheet.ncols)], + ) + ) + for row in range(1, sheet.nrows) + ] else: - raise HTTPException(status_code=422, detail="Only CSV, XLS, XLSX and XLSM participant files are supported") + raise HTTPException( + status_code=422, + detail="Only CSV, XLS, XLSX and XLSM participant files are supported", + ) normalized = [] for row in rows: lowered = {str(key).strip().lower().replace("-", "").replace("_", ""): value for key, value in row.items()} @@ -632,7 +1021,12 @@ def participant_rows(content: bytes, filename: str) -> tuple[list[dict], int]: @app.post("/api/organization/participants/import") -def import_participants(event_id: str = Form(...), file: UploadFile = File(...), user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def import_participants( + event_id: str = Form(...), + file: UploadFile = File(...), + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): event = db.scalar(select(Event).where(Event.id == event_id, Event.organization_id == user.organization_id)) if not event: raise HTTPException(status_code=404, detail="Event not found") @@ -646,19 +1040,42 @@ def import_participants(event_id: str = Form(...), file: UploadFile = File(...), duplicates += 1 continue raw_token, token_hash = new_opaque_token() - participant = Participant(organization_id=user.organization_id, event_id=event.id, name=row["name"], email=row["email"], enrollment_token_hash=token_hash, enrollment_expires_at=utcnow() + timedelta(days=14)) + participant = Participant( + organization_id=user.organization_id, + event_id=event.id, + name=row["name"], + email=row["email"], + enrollment_token_hash=token_hash, + enrollment_expires_at=utcnow() + timedelta(days=14), + ) db.add(participant) db.flush() enrollment_url = f"{settings.frontend_url}/enroll/{raw_token}" - email_item = queue_email(db, user.organization_id, participant.email, f"Find your photos from {event.name}", f"

Photos from {event.name} are being processed.

Verify your face securely to find photographs containing you.

Find My Photos

") + email_item = queue_email( + db, + user.organization_id, + participant.email, + f"Find your photos from {event.name}", + f"

Photos from {event.name} are being processed.

Verify your face securely to find photographs containing you.

Find My Photos

", + ) dispatch_email(db, email_item) if settings.environment == "development": development_links.append(enrollment_url) existing.add(row["email"]) imported += 1 - audit(db, user, "Participants imported", f"{event.name}: {imported} imported, {duplicates} duplicates") + audit( + db, + user, + "Participants imported", + f"{event.name}: {imported} imported, {duplicates} duplicates", + ) db.commit() - return {"imported": imported, "duplicates": duplicates, "invalid": max(0, total_rows - len(rows)), "developmentEnrollmentUrls": development_links} + return { + "imported": imported, + "duplicates": duplicates, + "invalid": max(0, total_rows - len(rows)), + "developmentEnrollmentUrls": development_links, + } SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} @@ -691,15 +1108,24 @@ def upload_candidates(files: list[UploadFile]): with zipfile.ZipFile(item.file) as archive: members = [entry for entry in archive.infolist() if not entry.is_dir()] if len(members) > MAX_ARCHIVE_FILES: - raise HTTPException(status_code=413, detail=f"ZIP archives may contain at most {MAX_ARCHIVE_FILES} files") + raise HTTPException( + status_code=413, + detail=f"ZIP archives may contain at most {MAX_ARCHIVE_FILES} files", + ) if sum(entry.file_size for entry in members) > MAX_ARCHIVE_UNCOMPRESSED_BYTES: - raise HTTPException(status_code=413, detail="ZIP archive expands beyond the 2 GB safety limit") + raise HTTPException( + status_code=413, + detail="ZIP archive expands beyond the 2 GB safety limit", + ) for entry in members: suffix = PurePosixPath(entry.filename).suffix.lower() if suffix not in SUPPORTED_IMAGE_EXTENSIONS: continue if entry.flag_bits & 0x1: - raise HTTPException(status_code=422, detail="Encrypted ZIP archives are not supported") + raise HTTPException( + status_code=422, + detail="Encrypted ZIP archives are not supported", + ) if entry.file_size > MAX_IMAGE_BYTES: yield PurePosixPath(entry.filename).name, b"", "" continue @@ -708,20 +1134,37 @@ def upload_candidates(files: list[UploadFile]): if len(content) > MAX_IMAGE_BYTES: yield PurePosixPath(entry.filename).name, b"", "" continue - yield PurePosixPath(entry.filename).name, content, mimetypes.guess_type(entry.filename)[0] or "application/octet-stream" + yield ( + PurePosixPath(entry.filename).name, + content, + mimetypes.guess_type(entry.filename)[0] or "application/octet-stream", + ) except zipfile.BadZipFile as exc: raise HTTPException(status_code=422, detail=f"{filename} is not a valid ZIP archive") from exc continue content = item.file.read(MAX_IMAGE_BYTES + 1) suffix = Path(filename).suffix.lower() - if len(content) <= MAX_IMAGE_BYTES and (item.content_type or "").startswith("image/") and suffix in SUPPORTED_IMAGE_EXTENSIONS: - yield filename, content, item.content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream" + if ( + len(content) <= MAX_IMAGE_BYTES + and (item.content_type or "").startswith("image/") + and suffix in SUPPORTED_IMAGE_EXTENSIONS + ): + yield ( + filename, + content, + item.content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream", + ) else: yield filename, b"", "" @app.post("/api/organization/photos", status_code=201) -def upload_photos(event_id: str = Form(...), files: list[UploadFile] = File(...), user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def upload_photos( + event_id: str = Form(...), + files: list[UploadFile] = File(...), + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): event = db.scalar(select(Event).where(Event.id == event_id, Event.organization_id == user.organization_id)) if not event: raise HTTPException(status_code=404, detail="Event not found") @@ -747,7 +1190,9 @@ def upload_photos(event_id: str = Form(...), files: list[UploadFile] = File(...) skipped.append({"name": filename, "reason": "storage quota would be exceeded"}) continue photo_id = secrets.token_hex(16) - safe_name = "".join(character for character in filename if character.isalnum() or character in ".-_") or "photo.jpg" + safe_name = ( + "".join(character for character in filename if character.isalnum() or character in ".-_") or "photo.jpg" + ) key = f"organizations/{user.organization_id}/events/{event.id}/original/{photo_id}-{safe_name}" thumbnail_key = f"organizations/{user.organization_id}/events/{event.id}/thumbnails/{photo_id}.jpg" try: @@ -757,17 +1202,47 @@ def upload_photos(event_id: str = Form(...), files: list[UploadFile] = File(...) storage.delete(key) storage.delete(thumbnail_key) raise HTTPException(status_code=502, detail=f"Object storage failed for {safe_name}") from exc - photo = Photo(id=photo_id, organization_id=user.organization_id, event_id=event.id, filename=safe_name, storage_key=key, thumbnail_storage_key=thumbnail_key, content_type=content_type, size_bytes=len(content), thumbnail_size_bytes=len(thumbnail), sha256=checksum, processing_status="queued") - job = ProcessingJob(organization_id=user.organization_id, event_id=event.id, photo_id=photo.id, job_type="face_pipeline", status="queued") + photo = Photo( + id=photo_id, + organization_id=user.organization_id, + event_id=event.id, + filename=safe_name, + storage_key=key, + thumbnail_storage_key=thumbnail_key, + content_type=content_type, + size_bytes=len(content), + thumbnail_size_bytes=len(thumbnail), + sha256=checksum, + processing_status="queued", + ) + job = ProcessingJob( + organization_id=user.organization_id, + event_id=event.id, + photo_id=photo.id, + job_type="face_pipeline", + status="queued", + ) db.add_all([photo, job]) db.flush() - uploaded.append({"id": photo.id, "filename": photo.filename, "sizeBytes": photo.size_bytes, "status": photo.processing_status}) + uploaded.append( + { + "id": photo.id, + "filename": photo.filename, + "sizeBytes": photo.size_bytes, + "status": photo.processing_status, + } + ) jobs.append(job.id) total_added += object_bytes user.organization.storage_used_bytes += total_added if uploaded: event.status = "processing" - audit(db, user, "Photo batch uploaded", f"{event.name}: {len(uploaded)} accepted, {len(skipped)} skipped") + audit( + db, + user, + "Photo batch uploaded", + f"{event.name}: {len(uploaded)} accepted, {len(skipped)} skipped", + ) db.commit() published = sum(publish_job({"job_id": job_id}) for job_id in jobs) return {"uploaded": uploaded, "skipped": skipped, "jobsPublished": published} @@ -775,32 +1250,127 @@ def upload_photos(event_id: str = Form(...), files: list[UploadFile] = File(...) @app.get("/api/organization/uploads") def list_uploads(user: User = Depends(require_org_member), db: Session = Depends(get_db)): - photos = db.scalars(select(Photo).where(Photo.organization_id == user.organization_id).order_by(Photo.uploaded_at.desc()).limit(200)).all() - return {"items": [{"id": item.id, "eventId": item.event_id, "event": item.event.name, "filename": item.filename, "sizeBytes": item.size_bytes, "thumbnailUrl": f"/api/media/{item.id}/thumbnail" if item.thumbnail_storage_key else None, "status": item.processing_status, "uploadedAt": iso(item.uploaded_at)} for item in photos]} + photos = db.scalars( + select(Photo).where(Photo.organization_id == user.organization_id).order_by(Photo.uploaded_at.desc()).limit(200) + ).all() + return { + "items": [ + { + "id": item.id, + "eventId": item.event_id, + "event": item.event.name, + "filename": item.filename, + "sizeBytes": item.size_bytes, + "thumbnailUrl": f"/api/media/{item.id}/thumbnail" if item.thumbnail_storage_key else None, + "status": item.processing_status, + "uploadedAt": iso(item.uploaded_at), + } + for item in photos + ] + } @app.get("/api/organization/processing") def processing(user: User = Depends(require_org_member), db: Session = Depends(get_db)): - jobs = db.scalars(select(ProcessingJob).where(ProcessingJob.organization_id == user.organization_id).order_by(ProcessingJob.created_at.desc()).limit(200)).all() - counts = {status: count for status, count in db.execute(select(ProcessingJob.status, func.count(ProcessingJob.id)).where(ProcessingJob.organization_id == user.organization_id).group_by(ProcessingJob.status)).all()} - faces = db.scalar(select(func.count(FaceDetection.id)).where(FaceDetection.organization_id == user.organization_id)) or 0 - return {"stats": {"activeJobs": counts.get("queued", 0) + counts.get("processing", 0), "failedJobs": counts.get("failed", 0), "facesDetected": faces}, "items": [{"id": item.id, "eventId": item.event_id, "photoId": item.photo_id, "type": item.job_type, "status": item.status, "progress": item.progress, "worker": item.worker or "queued", "error": item.error, "createdAt": iso(item.created_at)} for item in jobs]} + jobs = db.scalars( + select(ProcessingJob) + .where(ProcessingJob.organization_id == user.organization_id) + .order_by(ProcessingJob.created_at.desc()) + .limit(200) + ).all() + counts = { + status: count + for status, count in db.execute( + select(ProcessingJob.status, func.count(ProcessingJob.id)) + .where(ProcessingJob.organization_id == user.organization_id) + .group_by(ProcessingJob.status) + ).all() + } + faces = ( + db.scalar(select(func.count(FaceDetection.id)).where(FaceDetection.organization_id == user.organization_id)) + or 0 + ) + return { + "stats": { + "activeJobs": counts.get("queued", 0) + counts.get("processing", 0), + "failedJobs": counts.get("failed", 0), + "facesDetected": faces, + }, + "items": [ + { + "id": item.id, + "eventId": item.event_id, + "photoId": item.photo_id, + "type": item.job_type, + "status": item.status, + "progress": item.progress, + "worker": item.worker or "queued", + "error": item.error, + "createdAt": iso(item.created_at), + } + for item in jobs + ], + } @app.get("/api/organization/matches") -def matches(state: str | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): - statement = select(FaceMatch).where(FaceMatch.organization_id == user.organization_id).order_by(FaceMatch.created_at.desc()) +def matches( + state: str | None = None, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): + statement = ( + select(FaceMatch).where(FaceMatch.organization_id == user.organization_id).order_by(FaceMatch.created_at.desc()) + ) if state and state != "all": statement = statement.where(FaceMatch.state == state) rows = db.scalars(statement.limit(500)).all() - counts = {match_state: count for match_state, count in db.execute(select(FaceMatch.state, func.count(FaceMatch.id)).where(FaceMatch.organization_id == user.organization_id).group_by(FaceMatch.state)).all()} - total_faces = db.scalar(select(func.count(FaceDetection.id)).where(FaceDetection.organization_id == user.organization_id)) or 0 - return {"stats": {"facesDetected": total_faces, "high": counts.get("high", 0) + counts.get("approved", 0), "review": counts.get("review", 0), "low": counts.get("low", 0) + counts.get("rejected", 0)}, "items": [{"id": row.id, "event": row.detection.photo.event.name, "participant": row.participant.name if row.participant else "Unknown", "participantId": row.participant_id, "confidence": row.confidence, "photo": row.detection.photo.filename, "photoId": row.detection.photo_id, "state": row.state, "matchedAt": iso(row.created_at)} for row in rows]} + counts = { + match_state: count + for match_state, count in db.execute( + select(FaceMatch.state, func.count(FaceMatch.id)) + .where(FaceMatch.organization_id == user.organization_id) + .group_by(FaceMatch.state) + ).all() + } + total_faces = ( + db.scalar(select(func.count(FaceDetection.id)).where(FaceDetection.organization_id == user.organization_id)) + or 0 + ) + return { + "stats": { + "facesDetected": total_faces, + "high": counts.get("high", 0) + counts.get("approved", 0), + "review": counts.get("review", 0), + "low": counts.get("low", 0) + counts.get("rejected", 0), + }, + "items": [ + { + "id": row.id, + "event": row.detection.photo.event.name, + "participant": row.participant.name if row.participant else "Unknown", + "participantId": row.participant_id, + "confidence": row.confidence, + "photo": row.detection.photo.filename, + "photoId": row.detection.photo_id, + "state": row.state, + "matchedAt": iso(row.created_at), + } + for row in rows + ], + } @app.patch("/api/organization/matches/{match_id}") -def review_match(match_id: str, payload: MatchReviewInput, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): - row = db.scalar(select(FaceMatch).where(FaceMatch.id == match_id, FaceMatch.organization_id == user.organization_id)) +def review_match( + match_id: str, + payload: MatchReviewInput, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): + row = db.scalar( + select(FaceMatch).where(FaceMatch.id == match_id, FaceMatch.organization_id == user.organization_id) + ) if not row: raise HTTPException(status_code=404, detail="Match not found") if payload.decision not in {"approved", "rejected"}: @@ -814,37 +1384,105 @@ def review_match(match_id: str, payload: MatchReviewInput, user: User = Depends( def delivery_json(db: Session, row: Delivery) -> dict: - photos = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.participant_id == row.participant_id, FaceMatch.event_id == row.event_id, FaceMatch.state.in_(["high", "approved"]))) or 0 - return {"id": row.id, "participant": row.participant.name, "participantId": row.participant_id, "event": row.event.name, "eventId": row.event_id, "photos": photos, "status": row.status, "expires": iso(row.expires_at), "sentAt": iso(row.sent_at)} + photos = ( + db.scalar( + select(func.count(FaceMatch.id)).where( + FaceMatch.participant_id == row.participant_id, + FaceMatch.event_id == row.event_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0 + ) + return { + "id": row.id, + "participant": row.participant.name, + "participantId": row.participant_id, + "event": row.event.name, + "eventId": row.event_id, + "photos": photos, + "status": row.status, + "expires": iso(row.expires_at), + "sentAt": iso(row.sent_at), + } @app.get("/api/organization/deliveries") def deliveries(user: User = Depends(require_org_member), db: Session = Depends(get_db)): - rows = db.scalars(select(Delivery).where(Delivery.organization_id == user.organization_id).order_by(Delivery.created_at.desc())).all() - counts = {status: count for status, count in db.execute(select(Delivery.status, func.count(Delivery.id)).where(Delivery.organization_id == user.organization_id).group_by(Delivery.status)).all()} + rows = db.scalars( + select(Delivery).where(Delivery.organization_id == user.organization_id).order_by(Delivery.created_at.desc()) + ).all() + counts = { + status: count + for status, count in db.execute( + select(Delivery.status, func.count(Delivery.id)) + .where(Delivery.organization_id == user.organization_id) + .group_by(Delivery.status) + ).all() + } return {"stats": counts, "items": [delivery_json(db, item) for item in rows]} @app.post("/api/organization/deliveries/{participant_id}/send") -def send_delivery(participant_id: str, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): - participant = db.scalar(select(Participant).where(Participant.id == participant_id, Participant.organization_id == user.organization_id)) +def send_delivery( + participant_id: str, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): + participant = db.scalar( + select(Participant).where( + Participant.id == participant_id, + Participant.organization_id == user.organization_id, + ) + ) if not participant: raise HTTPException(status_code=404, detail="Participant not found") - match_count = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.participant_id == participant.id, FaceMatch.state.in_(["high", "approved"]))) or 0 + match_count = ( + db.scalar( + select(func.count(FaceMatch.id)).where( + FaceMatch.participant_id == participant.id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0 + ) if not match_count: raise HTTPException(status_code=409, detail="No approved photos are available") raw_token, token_hash = new_opaque_token() - delivery = db.scalar(select(Delivery).where(Delivery.participant_id == participant.id, Delivery.event_id == participant.event_id)) + delivery = db.scalar( + select(Delivery).where( + Delivery.participant_id == participant.id, + Delivery.event_id == participant.event_id, + ) + ) if not delivery: - delivery = Delivery(organization_id=user.organization_id, event_id=participant.event_id, participant_id=participant.id, gallery_token_hash=token_hash, expires_at=datetime.combine(participant.event.expires_at, datetime.min.time(), timezone.utc)) + delivery = Delivery( + organization_id=user.organization_id, + event_id=participant.event_id, + participant_id=participant.id, + gallery_token_hash=token_hash, + expires_at=datetime.combine(participant.event.expires_at, datetime.min.time(), timezone.utc), + ) db.add(delivery) else: delivery.gallery_token_hash = token_hash db.flush() gallery_url = f"{settings.frontend_url}/gallery/{raw_token}" - email_item = queue_email(db, user.organization_id, participant.email, f"Your photos from {participant.event.name} are ready", f"

Hello {participant.name},

We found {match_count} photos containing you.

View My Photos

This private link expires {participant.event.expires_at.isoformat()}.

", delivery_id=delivery.id) + email_item = queue_email( + db, + user.organization_id, + participant.email, + f"Your photos from {participant.event.name} are ready", + f"

Hello {participant.name},

We found {match_count} photos containing you.

View My Photos

This private link expires {participant.event.expires_at.isoformat()}.

", + delivery_id=delivery.id, + ) dispatch_email(db, email_item) - audit(db, user, "Gallery delivered", f"{participant.event.name}: {participant.email} ({match_count} photos)") + audit( + db, + user, + "Gallery delivered", + f"{participant.event.name}: {participant.email} ({match_count} photos)", + ) db.commit() result = delivery_json(db, delivery) if settings.environment == "development": @@ -863,10 +1501,18 @@ def organization_settings(user: User = Depends(require_org_member), db: Session @app.patch("/api/organization/settings") -def update_settings(payload: SettingsInput, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def update_settings( + payload: SettingsInput, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): values = payload.model_dump(exclude_unset=True) for key, value in values.items(): - setattr(user.organization, {"contactName": "contact_name", "contactEmail": "contact_email"}.get(key, key), value) + setattr( + user.organization, + {"contactName": "contact_name", "contactEmail": "contact_email"}.get(key, key), + value, + ) audit(db, user, "Organization profile updated", ", ".join(values)) db.commit() return organization_json(db, user.organization) @@ -875,25 +1521,74 @@ def update_settings(payload: SettingsInput, user: User = Depends(require_org_adm @app.get("/api/organization/dashboard") def organization_dashboard(user: User = Depends(require_org_member), db: Session = Depends(get_db)): events = org_events(db, user.organization_id) - return {"organization": organization_json(db, user.organization), "events": [event_json(db, item) for item in events], "stats": {"events": len(events), "photos": db.scalar(select(func.count(Photo.id)).where(Photo.organization_id == user.organization_id)) or 0, "participants": db.scalar(select(func.count(Participant.id)).where(Participant.organization_id == user.organization_id)) or 0, "enrolled": db.scalar(select(func.count(Participant.id)).where(Participant.organization_id == user.organization_id, Participant.enrollment_status == "verified")) or 0, "matched": db.scalar(select(func.count(func.distinct(FaceMatch.participant_id))).where(FaceMatch.organization_id == user.organization_id, FaceMatch.participant_id.is_not(None), FaceMatch.state.in_(["high", "approved"]))) or 0, "delivered": db.scalar(select(func.count(Delivery.id)).where(Delivery.organization_id == user.organization_id, Delivery.status == "delivered")) or 0}} + return { + "organization": organization_json(db, user.organization), + "events": [event_json(db, item) for item in events], + "stats": { + "events": len(events), + "photos": db.scalar(select(func.count(Photo.id)).where(Photo.organization_id == user.organization_id)) or 0, + "participants": db.scalar( + select(func.count(Participant.id)).where(Participant.organization_id == user.organization_id) + ) + or 0, + "enrolled": db.scalar( + select(func.count(Participant.id)).where( + Participant.organization_id == user.organization_id, + Participant.enrollment_status == "verified", + ) + ) + or 0, + "matched": db.scalar( + select(func.count(func.distinct(FaceMatch.participant_id))).where( + FaceMatch.organization_id == user.organization_id, + FaceMatch.participant_id.is_not(None), + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0, + "delivered": db.scalar( + select(func.count(Delivery.id)).where( + Delivery.organization_id == user.organization_id, + Delivery.status == "delivered", + ) + ) + or 0, + }, + } @app.get("/api/media/{photo_id}") -def media(photo_id: str, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def media( + photo_id: str, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): photo = db.scalar(select(Photo).where(Photo.id == photo_id, Photo.organization_id == user.organization_id)) if not photo: raise HTTPException(status_code=404, detail="Photo not found") content, content_type = storage.read(photo.storage_key) - return Response(content, media_type=content_type, headers={"Cache-Control": "private, max-age=300"}) + return Response( + content, + media_type=content_type, + headers={"Cache-Control": "private, max-age=300"}, + ) @app.get("/api/media/{photo_id}/thumbnail") -def media_thumbnail(photo_id: str, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def media_thumbnail( + photo_id: str, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): photo = db.scalar(select(Photo).where(Photo.id == photo_id, Photo.organization_id == user.organization_id)) if not photo or not photo.thumbnail_storage_key: raise HTTPException(status_code=404, detail="Thumbnail not found") content, _ = storage.read(photo.thumbnail_storage_key) - return Response(content, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=3600"}) + return Response( + content, + media_type="image/jpeg", + headers={"Cache-Control": "private, max-age=3600"}, + ) @app.get("/api/public/enroll/{token}") @@ -901,11 +1596,23 @@ def enrollment_info(token: str, db: Session = Depends(get_db)): participant = db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) if not participant or participant.enrollment_expires_at < utcnow(): raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") - return {"participant": participant.name, "event": participant.event.name, "organization": participant.event.organization.name, "status": participant.enrollment_status, "expiresAt": iso(participant.enrollment_expires_at)} + return { + "participant": participant.name, + "event": participant.event.name, + "organization": participant.event.organization.name, + "status": participant.enrollment_status, + "expiresAt": iso(participant.enrollment_expires_at), + } @app.post("/api/public/enroll/{token}") -def enroll_face(token: str, request: Request, consent: bool = Form(...), selfie: UploadFile = File(...), db: Session = Depends(get_db)): +def enroll_face( + token: str, + request: Request, + consent: bool = Form(...), + selfie: UploadFile = File(...), + db: Session = Depends(get_db), +): participant = db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) if not participant or participant.enrollment_expires_at < utcnow(): raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") @@ -915,13 +1622,20 @@ def enroll_face(token: str, request: Request, consent: bool = Form(...), selfie: if not content or not (selfie.content_type or "").startswith("image/"): raise HTTPException(status_code=422, detail="A valid selfie image is required") try: - result = ml_embedding(content, selfie.filename or "selfie.jpg", selfie.content_type or "image/jpeg") + result = ml_embedding( + content, + selfie.filename or "selfie.jpg", + selfie.content_type or "image/jpeg", + ) except Exception as exc: raise HTTPException(status_code=422, detail=f"A clear face could not be enrolled: {exc}") from exc key = f"organizations/{participant.organization_id}/events/{participant.event_id}/enrollments/{participant.id}.jpg" previous_size = participant.enrollment.size_bytes if participant.enrollment else 0 added_size = len(content) - previous_size - if participant.event.organization.storage_used_bytes + added_size > participant.event.organization.storage_limit_bytes: + if ( + participant.event.organization.storage_used_bytes + added_size + > participant.event.organization.storage_limit_bytes + ): raise HTTPException(status_code=413, detail="Organization storage quota would be exceeded") storage.put(key, content, selfie.content_type or "image/jpeg") enrollment = participant.enrollment or FaceEnrollment( @@ -941,20 +1655,31 @@ def enroll_face(token: str, request: Request, consent: bool = Form(...), selfie: db.add(enrollment) participant.enrollment_status = "verified" participant.consented_at = utcnow() - db.add(Consent( - organization_id=participant.organization_id, - event_id=participant.event_id, - participant_id=participant.id, - consent_type="face_enrollment", - policy_version=settings.consent_policy_version, - accepted=True, - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get("user-agent"), - )) + db.add( + Consent( + organization_id=participant.organization_id, + event_id=participant.event_id, + participant_id=participant.id, + consent_type="face_enrollment", + policy_version=settings.consent_policy_version, + accepted=True, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + ) participant.event.organization.storage_used_bytes += added_size - audit(db, None, "Face enrollment completed", f"{participant.event.name}: {participant.email}", organization_id=participant.organization_id) + audit( + db, + None, + "Face enrollment completed", + f"{participant.event.name}: {participant.email}", + organization_id=participant.organization_id, + ) db.commit() - return {"status": "verified", "message": "Your face was enrolled securely. We will email you when your private gallery is ready."} + return { + "status": "verified", + "message": "Your face was enrolled securely. We will email you when your private gallery is ready.", + } @app.get("/api/public/gallery/{token}") @@ -962,9 +1687,31 @@ def public_gallery(token: str, db: Session = Depends(get_db)): delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) if not delivery or delivery.expires_at < utcnow(): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - matches = db.scalars(select(FaceMatch).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceMatch.state.in_(["high", "approved"]))).all() + matches = db.scalars( + select(FaceMatch).where( + FaceMatch.participant_id == delivery.participant_id, + FaceMatch.event_id == delivery.event_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ).all() photos = {row.detection.photo.id: row.detection.photo for row in matches} - return {"participant": delivery.participant.name, "event": delivery.event.name, "organization": delivery.event.organization.name, "expiresAt": iso(delivery.expires_at), "photos": [{"id": photo.id, "filename": photo.filename, "url": f"/api/public/gallery/{token}/photos/{photo.id}", "thumbnailUrl": f"/api/public/gallery/{token}/photos/{photo.id}/thumbnail" if photo.thumbnail_storage_key else f"/api/public/gallery/{token}/photos/{photo.id}"} for photo in photos.values()]} + return { + "participant": delivery.participant.name, + "event": delivery.event.name, + "organization": delivery.event.organization.name, + "expiresAt": iso(delivery.expires_at), + "photos": [ + { + "id": photo.id, + "filename": photo.filename, + "url": f"/api/public/gallery/{token}/photos/{photo.id}", + "thumbnailUrl": f"/api/public/gallery/{token}/photos/{photo.id}/thumbnail" + if photo.thumbnail_storage_key + else f"/api/public/gallery/{token}/photos/{photo.id}", + } + for photo in photos.values() + ], + } @app.get("/api/public/gallery/{token}/photos/{photo_id}") @@ -972,11 +1719,27 @@ def public_gallery_photo(token: str, photo_id: str, db: Session = Depends(get_db delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) if not delivery or delivery.expires_at < utcnow(): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - permitted = db.scalar(select(FaceMatch).join(FaceDetection).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceDetection.photo_id == photo_id, FaceMatch.state.in_(["high", "approved"]))) + permitted = db.scalar( + select(FaceMatch) + .join(FaceDetection) + .where( + FaceMatch.participant_id == delivery.participant_id, + FaceMatch.event_id == delivery.event_id, + FaceDetection.photo_id == photo_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) if not permitted: raise HTTPException(status_code=404, detail="Photo not found") content, content_type = storage.read(permitted.detection.photo.storage_key) - return Response(content, media_type=content_type, headers={"Cache-Control": "private, max-age=300", "Content-Disposition": f'inline; filename="{permitted.detection.photo.filename}"'}) + return Response( + content, + media_type=content_type, + headers={ + "Cache-Control": "private, max-age=300", + "Content-Disposition": f'inline; filename="{permitted.detection.photo.filename}"', + }, + ) @app.get("/api/public/gallery/{token}/photos/{photo_id}/thumbnail") @@ -984,11 +1747,24 @@ def public_gallery_thumbnail(token: str, photo_id: str, db: Session = Depends(ge delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) if not delivery or delivery.expires_at < utcnow(): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - permitted = db.scalar(select(FaceMatch).join(FaceDetection).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceDetection.photo_id == photo_id, FaceMatch.state.in_(["high", "approved"]))) + permitted = db.scalar( + select(FaceMatch) + .join(FaceDetection) + .where( + FaceMatch.participant_id == delivery.participant_id, + FaceMatch.event_id == delivery.event_id, + FaceDetection.photo_id == photo_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) if not permitted or not permitted.detection.photo.thumbnail_storage_key: raise HTTPException(status_code=404, detail="Thumbnail not found") content, _ = storage.read(permitted.detection.photo.thumbnail_storage_key) - return Response(content, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=3600"}) + return Response( + content, + media_type="image/jpeg", + headers={"Cache-Control": "private, max-age=3600"}, + ) @app.get("/api/public/gallery/{token}/download") @@ -996,7 +1772,13 @@ def public_gallery_download(token: str, photoIds: str | None = None, db: Session delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) if not delivery or delivery.expires_at < utcnow(): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - matches = db.scalars(select(FaceMatch).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceMatch.state.in_(["high", "approved"]))).all() + matches = db.scalars( + select(FaceMatch).where( + FaceMatch.participant_id == delivery.participant_id, + FaceMatch.event_id == delivery.event_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ).all() permitted = {row.detection.photo.id: row.detection.photo for row in matches} requested = {value for value in (photoIds or "").split(",") if value} photos = [photo for photo_id, photo in permitted.items() if not requested or photo_id in requested] @@ -1008,11 +1790,28 @@ def public_gallery_download(token: str, photoIds: str | None = None, db: Session content, _ = storage.read(photo.storage_key) archive.writestr(f"{photo.id[:8]}-{photo.filename}", content) archive_buffer.seek(0) - base_name = "".join(character for character in delivery.event.name.lower().replace(" ", "-") if character.isalnum() or character in "-_") or "event" + base_name = ( + "".join( + character + for character in delivery.event.name.lower().replace(" ", "-") + if character.isalnum() or character in "-_" + ) + or "event" + ) + def chunks(): while content := archive_buffer.read(1024 * 1024): yield content - return StreamingResponse(chunks(), media_type="application/zip", headers={"Cache-Control": "no-store", "Content-Disposition": f'attachment; filename="{base_name}-photos.zip"'}, background=BackgroundTask(archive_buffer.close)) + + return StreamingResponse( + chunks(), + media_type="application/zip", + headers={ + "Cache-Control": "no-store", + "Content-Disposition": f'attachment; filename="{base_name}-photos.zip"', + }, + background=BackgroundTask(archive_buffer.close), + ) from .v2 import router as v2_router # noqa: E402 diff --git a/backend/app/models.py b/backend/app/models.py index 1fa15e6..17664f5 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -66,7 +66,9 @@ class Organization(Base): class User(Base): __tablename__ = "users" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) - organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True + ) name: Mapped[str] = mapped_column(String(120)) email: Mapped[str] = mapped_column(String(254), unique=True, index=True) password_hash: Mapped[str | None] = mapped_column(String(300), nullable=True) @@ -131,7 +133,9 @@ class FaceEnrollment(Base): embedding: Mapped[list] = mapped_column(JSON) embedding_vector: Mapped[list | None] = mapped_column(Vector(512), nullable=True) detector_confidence: Mapped[float] = mapped_column(Float) - organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True + ) event_id: Mapped[str | None] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), nullable=True, index=True) status: Mapped[str] = mapped_column(String(24), default="valid", index=True) model_name: Mapped[str] = mapped_column(String(120), default="adaface-ir101-ms1mv2") @@ -191,7 +195,9 @@ class FaceMatch(Base): organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) event_id: Mapped[str] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) detection_id: Mapped[str] = mapped_column(ForeignKey("face_detections.id", ondelete="CASCADE"), unique=True) - participant_id: Mapped[str | None] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), nullable=True, index=True) + participant_id: Mapped[str | None] = mapped_column( + ForeignKey("participants.id", ondelete="CASCADE"), nullable=True, index=True + ) confidence: Mapped[float] = mapped_column(Float) second_best_score: Mapped[float | None] = mapped_column(Float, nullable=True) margin: Mapped[float | None] = mapped_column(Float, nullable=True) @@ -264,8 +270,12 @@ class GalleryExport(Base): class EmailOutbox(Base): __tablename__ = "email_outbox" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) - organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True) - delivery_id: Mapped[str | None] = mapped_column(ForeignKey("deliveries.id", ondelete="SET NULL"), nullable=True, index=True) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True + ) + delivery_id: Mapped[str | None] = mapped_column( + ForeignKey("deliveries.id", ondelete="SET NULL"), nullable=True, index=True + ) recipient: Mapped[str] = mapped_column(String(254)) subject: Mapped[str] = mapped_column(String(240)) html: Mapped[str] = mapped_column(Text) @@ -283,7 +293,9 @@ class EmailOutbox(Base): class AuditLog(Base): __tablename__ = "audit_logs" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) - organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id", ondelete="SET NULL"), nullable=True, index=True) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id", ondelete="SET NULL"), nullable=True, index=True + ) actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) actor: Mapped[str] = mapped_column(String(254)) action: Mapped[str] = mapped_column(String(120), index=True) @@ -333,6 +345,10 @@ class ParticipantEnrollmentToken(Base): token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + pending_storage_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + pending_content_type: Mapped[str | None] = mapped_column(String(120), nullable=True) + pending_size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + pending_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) @@ -403,7 +419,9 @@ class StorageUsageLedger(Base): __tablename__ = "storage_usage_ledger" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), index=True) - event_id: Mapped[str | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) + event_id: Mapped[str | None] = mapped_column( + ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True + ) photo_id: Mapped[str | None] = mapped_column(ForeignKey("photos.id", ondelete="SET NULL"), nullable=True) operation: Mapped[str] = mapped_column(String(24)) bytes: Mapped[int] = mapped_column(BigInteger) @@ -415,7 +433,9 @@ class OutboxEvent(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4) aggregate_type: Mapped[str] = mapped_column(String(80)) aggregate_id: Mapped[str] = mapped_column(String(36), index=True) - organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=True, index=True + ) event_type: Mapped[str] = mapped_column(String(160), index=True) event_version: Mapped[int] = mapped_column(Integer, default=1) payload: Mapped[dict] = mapped_column(JSON) diff --git a/backend/app/serializers.py b/backend/app/serializers.py index 781559f..2a70e08 100644 --- a/backend/app/serializers.py +++ b/backend/app/serializers.py @@ -40,7 +40,9 @@ def user_json(user: User) -> dict: def organization_json(db: Session, organization: Organization) -> dict: users = db.scalar(select(func.count(User.id)).where(User.organization_id == organization.id)) or 0 events = db.scalar(select(func.count(Event.id)).where(Event.organization_id == organization.id)) or 0 - next_expiry = db.scalar(select(func.min(Event.expires_at)).where(Event.organization_id == organization.id, Event.status != "expired")) + next_expiry = db.scalar( + select(func.min(Event.expires_at)).where(Event.organization_id == organization.id, Event.status != "expired") + ) return { "id": organization.id, "name": organization.name, @@ -66,12 +68,66 @@ def event_json(db: Session, event: Event) -> dict: photos = db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event.id)) or 0 faces = db.scalar(select(func.count(FaceDetection.id)).where(FaceDetection.event_id == event.id)) or 0 participants = db.scalar(select(func.count(Participant.id)).where(Participant.event_id == event.id)) or 0 - enrolled = db.scalar(select(func.count(Participant.id)).where(Participant.event_id == event.id, Participant.enrollment_status == "verified")) or 0 - matched = db.scalar(select(func.count(func.distinct(FaceMatch.participant_id))).where(FaceMatch.event_id == event.id, FaceMatch.participant_id.is_not(None), FaceMatch.state.in_(["high", "approved"]))) or 0 - delivered = db.scalar(select(func.count(Delivery.id)).where(Delivery.event_id == event.id, Delivery.status == "delivered")) or 0 - return {"id": event.id, "name": event.name, "description": event.description, "date": iso(event.event_date), "location": event.location, "retentionDays": event.retention_days, "expiresAt": iso(event.expires_at), "status": event.status, "photos": photos, "facesDetected": faces, "participants": participants, "enrolled": enrolled, "matched": matched, "delivered": delivered, "createdAt": iso(event.created_at)} + enrolled = ( + db.scalar( + select(func.count(Participant.id)).where( + Participant.event_id == event.id, + Participant.enrollment_status == "verified", + ) + ) + or 0 + ) + matched = ( + db.scalar( + select(func.count(func.distinct(FaceMatch.participant_id))).where( + FaceMatch.event_id == event.id, + FaceMatch.participant_id.is_not(None), + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0 + ) + delivered = ( + db.scalar(select(func.count(Delivery.id)).where(Delivery.event_id == event.id, Delivery.status == "delivered")) + or 0 + ) + return { + "id": event.id, + "name": event.name, + "description": event.description, + "date": iso(event.event_date), + "location": event.location, + "retentionDays": event.retention_days, + "expiresAt": iso(event.expires_at), + "status": event.status, + "photos": photos, + "facesDetected": faces, + "participants": participants, + "enrolled": enrolled, + "matched": matched, + "delivered": delivered, + "createdAt": iso(event.created_at), + } def participant_json(db: Session, participant: Participant) -> dict: - matches = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.participant_id == participant.id, FaceMatch.state.in_(["high", "approved"]))) or 0 - return {"id": participant.id, "eventId": participant.event_id, "event": participant.event.name, "name": participant.name, "email": participant.email, "enrollment": participant.enrollment_status, "delivery": participant.delivery_status, "matches": matches, "uploadedAt": iso(participant.created_at)} + matches = ( + db.scalar( + select(func.count(FaceMatch.id)).where( + FaceMatch.participant_id == participant.id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0 + ) + return { + "id": participant.id, + "eventId": participant.event_id, + "event": participant.event.name, + "name": participant.name, + "email": participant.email, + "enrollment": participant.enrollment_status, + "delivery": participant.delivery_status, + "matches": matches, + "uploadedAt": iso(participant.created_at), + } diff --git a/backend/app/v2.py b/backend/app/v2.py index 3c70f1b..b1217dd 100644 --- a/backend/app/v2.py +++ b/backend/app/v2.py @@ -111,7 +111,13 @@ def ok(data=None, request: Request | None = None, **meta): - return {"data": data, "meta": {"request_id": getattr(request.state, "request_id", None) if request else None, **meta}} + return { + "data": data, + "meta": { + "request_id": getattr(request.state, "request_id", None) if request else None, + **meta, + }, + } def add_audit( @@ -184,7 +190,12 @@ def set_refresh_cookie(response: Response, token: str) -> None: def clear_refresh_cookie(response: Response) -> None: - response.delete_cookie("fdx_refresh", path="/api/v2/auth", secure=settings.environment == "production", samesite="strict") + response.delete_cookie( + "fdx_refresh", + path="/api/v2/auth", + secure=settings.environment == "production", + samesite="strict", + ) def tenant_event(db: Session, user: User, event_id: str, lock: bool = False) -> Event: @@ -192,14 +203,17 @@ def tenant_event(db: Session, user: User, event_id: str, lock: bool = False) -> if lock: statement = statement.with_for_update() event = db.scalar(statement) - if not event: + if not event or event.status.upper() in {"DELETION_PENDING", "DELETED"}: raise HTTPException(status_code=404, detail="Event was not found") return event def pagination(page: int, page_size: int) -> tuple[int, int]: if page < 1 or page_size < 1 or page_size > 100: - raise HTTPException(status_code=422, detail="page must be >= 1 and page_size must be between 1 and 100") + raise HTTPException( + status_code=422, + detail="page must be >= 1 and page_size must be between 1 and 100", + ) return (page - 1) * page_size, page_size @@ -207,12 +221,22 @@ def reserve_idempotency(db: Session, user: User, key: str | None, body: str) -> if not key: return None digest = hashlib.sha256(body.encode()).hexdigest() - record = db.scalar(select(IdempotencyRecord).where(IdempotencyRecord.user_id == user.id, IdempotencyRecord.key == key)) + record = db.scalar( + select(IdempotencyRecord).where(IdempotencyRecord.user_id == user.id, IdempotencyRecord.key == key) + ) if record: if record.request_hash != digest: - raise HTTPException(status_code=409, detail="Idempotency key was already used for a different request") + raise HTTPException( + status_code=409, + detail="Idempotency key was already used for a different request", + ) return record - record = IdempotencyRecord(user_id=user.id, key=key, request_hash=digest, expires_at=utcnow() + timedelta(days=1)) + record = IdempotencyRecord( + user_id=user.id, + key=key, + request_hash=digest, + expires_at=utcnow() + timedelta(days=1), + ) db.add(record) db.flush() return record @@ -307,12 +331,39 @@ class PresignInput(BaseModel): files: list[UploadObjectInput] = Field(min_length=1, max_length=1000) +class MultipartPartInput(BaseModel): + part_number: int = Field(ge=1, le=10_000) + etag: str = Field(min_length=1, max_length=200) + + +class CompleteMultipartInput(BaseModel): + upload_id: str = Field(min_length=1, max_length=500) + parts: list[MultipartPartInput] = Field(min_length=1, max_length=10_000) + + +class BulkInvitationInput(BaseModel): + enrollment_status: list[str] = Field(default_factory=lambda: ["invited", "opened"]) + search: str | None = Field(default=None, max_length=120) + + +class EnrollmentUploadInput(BaseModel): + filename: str = Field(min_length=1, max_length=260) + content_type: str + size_bytes: int = Field(gt=0) + sha256: str = Field(pattern=r"^[a-fA-F0-9]{64}$") + + class MatchReviewInput(BaseModel): decision: str @router.post("/auth/login") -def login(payload: LoginInput, response: Response, request: Request, db: Session = Depends(get_db)): +def login( + payload: LoginInput, + response: Response, + request: Request, + db: Session = Depends(get_db), +): email = str(payload.email).lower() check_login_rate_limit(request, email) user = find_user_by_email(db, email) @@ -342,18 +393,34 @@ def login(payload: LoginInput, response: Response, request: Request, db: Session @router.post("/auth/refresh") -def refresh(response: Response, request: Request, fdx_refresh: str | None = Cookie(default=None), db: Session = Depends(get_db)): +def refresh( + response: Response, + request: Request, + fdx_refresh: str | None = Cookie(default=None), + db: Session = Depends(get_db), +): if not fdx_refresh: raise HTTPException(status_code=401, detail="Refresh session is required") user, raw_refresh, session = rotate_refresh_session(db, fdx_refresh, request) tokens = access_token(user, session.id) db.commit() set_refresh_cookie(response, raw_refresh) - return ok({"access_token": tokens["access_token"], "expires_in": tokens["expires_in"], "user": user_v2(user)}, request) + return ok( + { + "access_token": tokens["access_token"], + "expires_in": tokens["expires_in"], + "user": user_v2(user), + }, + request, + ) @router.post("/auth/logout", status_code=204) -def logout(response: Response, fdx_refresh: str | None = Cookie(default=None), db: Session = Depends(get_db)): +def logout( + response: Response, + fdx_refresh: str | None = Cookie(default=None), + db: Session = Depends(get_db), +): if fdx_refresh: session = db.scalar(select(RefreshSession).where(RefreshSession.refresh_token_hash == hash_token(fdx_refresh))) if session and not session.revoked_at: @@ -374,34 +441,69 @@ def forgot_password(payload: ForgotPasswordInput, request: Request, db: Session check_public_rate_limit(request, "forgot-password", str(payload.email).lower(), 5, 300) user = find_user_by_email(db, str(payload.email).lower()) if user and user.status == "active": - db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id, PasswordResetToken.consumed_at.is_(None)).delete() + db.query(PasswordResetToken).filter( + PasswordResetToken.user_id == user.id, + PasswordResetToken.consumed_at.is_(None), + ).delete() raw_token, token_hash = new_opaque_token() - db.add(PasswordResetToken(user_id=user.id, token_hash=token_hash, expires_at=utcnow() + timedelta(minutes=settings.password_reset_minutes))) + db.add( + PasswordResetToken( + user_id=user.id, + token_hash=token_hash, + expires_at=utcnow() + timedelta(minutes=settings.password_reset_minutes), + ) + ) url = f"{settings.frontend_url}/reset-password/{raw_token}" - item = queue_email(db, user.organization_id, user.email, "Reset your FDX password", f"

Reset password

") + item = queue_email( + db, + user.organization_id, + user.email, + "Reset your FDX password", + f"

Reset password

", + ) dispatch_email(db, item) add_audit(db, user, "auth.password_reset.requested", "Password reset requested") db.commit() - return ok({"message": "If the account exists, a password reset email has been queued."}, request) + return ok( + {"message": "If the account exists, a password reset email has been queued."}, + request, + ) @router.post("/auth/reset-password") def reset_password(payload: ResetPasswordInput, request: Request, db: Session = Depends(get_db)): - token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_token(payload.token)).with_for_update()) + token = db.scalar( + select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_token(payload.token)).with_for_update() + ) if not token or token.consumed_at or token.expires_at <= utcnow(): raise HTTPException(status_code=404, detail="Password reset token is invalid or expired") user = db.get(User, token.user_id) user.password_hash = hash_password(payload.password) token.consumed_at = utcnow() - db.query(RefreshSession).filter(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}) - add_audit(db, user, "auth.password_reset.completed", "Password changed and sessions revoked") + db.query(RefreshSession).filter(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None)).update( + {"revoked_at": utcnow()} + ) + add_audit( + db, + user, + "auth.password_reset.completed", + "Password changed and sessions revoked", + ) db.commit() return ok({"message": "Password reset completed."}, request) @router.post("/auth/invitations/{token}/accept") -def accept_invitation(token: str, payload: PasswordInput, response: Response, request: Request, db: Session = Depends(get_db)): - invitation = db.scalar(select(UserInvitation).where(UserInvitation.token_hash == hash_token(token)).with_for_update()) +def accept_invitation( + token: str, + payload: PasswordInput, + response: Response, + request: Request, + db: Session = Depends(get_db), +): + invitation = db.scalar( + select(UserInvitation).where(UserInvitation.token_hash == hash_token(token)).with_for_update() + ) if not invitation or invitation.accepted_at or invitation.revoked_at or invitation.expires_at <= utcnow(): raise HTTPException(status_code=404, detail="Invitation is invalid or expired") user = db.get(User, invitation.user_id) @@ -413,11 +515,22 @@ def accept_invitation(token: str, payload: PasswordInput, response: Response, re tokens = access_token(user, session.id) db.commit() set_refresh_cookie(response, raw_refresh) - return ok({"access_token": tokens["access_token"], "expires_in": tokens["expires_in"], "user": user_v2(user)}, request) + return ok( + { + "access_token": tokens["access_token"], + "expires_in": tokens["expires_in"], + "user": user_v2(user), + }, + request, + ) @router.get("/admin/dashboard") -def admin_dashboard(request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_dashboard( + request: Request, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): def count(model, *filters): return db.scalar(select(func.count(model.id)).where(*filters)) or 0 @@ -429,12 +542,22 @@ def count(model, *filters): "events_total": count(Event), "photos_total": count(Photo), "storage_used_bytes": db.scalar(select(func.coalesce(func.sum(Organization.storage_used_bytes), 0))) or 0, - "jobs_queued": count(ProcessingJob, ProcessingJob.status.in_(["queued", "QUEUED", "RETRY_SCHEDULED"])), + "jobs_queued": count( + ProcessingJob, + ProcessingJob.status.in_(["queued", "QUEUED", "RETRY_SCHEDULED"]), + ), "jobs_running": count(ProcessingJob, ProcessingJob.status.in_(["processing", "RUNNING"])), - "jobs_failed": count(ProcessingJob, ProcessingJob.status.in_(["failed", "FAILED", "DEAD_LETTERED"])), + "jobs_failed": count( + ProcessingJob, + ProcessingJob.status.in_(["failed", "FAILED", "DEAD_LETTERED"]), + ), "emails_sent": count(EmailOutbox, EmailOutbox.status == "sent"), "emails_failed": count(EmailOutbox, EmailOutbox.status == "failed"), - "expiring_events": count(Event, Event.expires_at <= date.today() + timedelta(days=7), Event.status.notin_(["expired", "DELETED"])), + "expiring_events": count( + Event, + Event.expires_at <= date.today() + timedelta(days=7), + Event.status.notin_(["expired", "DELETED"]), + ), } return ok(data, request) @@ -442,11 +565,25 @@ def count(model, *filters): @router.get("/admin/system-health") def system_health(request: Request, _: User = Depends(require_super_admin)): services = dependency_health() - return ok({"status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", "services": services}, request) + return ok( + { + "status": "healthy" if all(item["status"] == "healthy" for item in services) else "degraded", + "services": services, + }, + request, + ) @router.get("/admin/organizations") -def organizations(request: Request, page: int = 1, page_size: int = 50, search: str | None = None, status: str | None = None, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def organizations( + request: Request, + page: int = 1, + page_size: int = 50, + search: str | None = None, + status: str | None = None, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): offset, limit = pagination(page, page_size) filters = [] if search: @@ -454,12 +591,25 @@ def organizations(request: Request, page: int = 1, page_size: int = 50, search: if status: filters.append(Organization.status == status.lower()) total = db.scalar(select(func.count(Organization.id)).where(*filters)) or 0 - rows = db.scalars(select(Organization).where(*filters).order_by(Organization.created_at.desc()).offset(offset).limit(limit)).all() - return ok([organization_json(db, row) for row in rows], request, page=page, page_size=page_size, total=total) + rows = db.scalars( + select(Organization).where(*filters).order_by(Organization.created_at.desc()).offset(offset).limit(limit) + ).all() + return ok( + [organization_json(db, row) for row in rows], + request, + page=page, + page_size=page_size, + total=total, + ) @router.post("/admin/organizations", status_code=201) -def create_organization(payload: OrganizationInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def create_organization( + payload: OrganizationInput, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = Organization( name=payload.name.strip(), type=payload.organization_type, @@ -483,26 +633,52 @@ def create_organization(payload: OrganizationInput, request: Request, user: User @router.get("/admin/organizations/{organization_id}") -def organization_detail(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def organization_detail( + organization_id: str, + request: Request, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(Organization, organization_id) if not item: raise HTTPException(status_code=404, detail="Organization was not found") data = organization_json(db, item) data["recent_audit"] = [ - {"id": row.id, "action": row.action, "details": row.details, "created_at": row.created_at.isoformat()} - for row in db.scalars(select(AuditLog).where(AuditLog.organization_id == item.id).order_by(AuditLog.created_at.desc()).limit(20)).all() + { + "id": row.id, + "action": row.action, + "details": row.details, + "created_at": row.created_at.isoformat(), + } + for row in db.scalars( + select(AuditLog).where(AuditLog.organization_id == item.id).order_by(AuditLog.created_at.desc()).limit(20) + ).all() ] return ok(data, request) @router.patch("/admin/organizations/{organization_id}") -def update_organization(organization_id: str, payload: OrganizationUpdate, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def update_organization( + organization_id: str, + payload: OrganizationUpdate, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(Organization, organization_id) if not item: raise HTTPException(status_code=404, detail="Organization was not found") - mapping = {"primary_email": "contact_email", "default_retention_days": "retention_days", "account_expires_at": "expires_at"} + mapping = { + "primary_email": "contact_email", + "default_retention_days": "retention_days", + "account_expires_at": "expires_at", + } for key, value in payload.model_dump(exclude_unset=True).items(): - setattr(item, mapping.get(key, key), str(value).lower() if key == "primary_email" else value) + setattr( + item, + mapping.get(key, key), + str(value).lower() if key == "primary_email" else value, + ) add_audit(db, user, "organization.updated", ", ".join(payload.model_fields_set), item.id) db.commit() return ok(organization_json(db, item), request) @@ -515,42 +691,89 @@ def set_org_status(organization_id: str, target: str, request: Request, user: Us item.status = target if target != "active": session_ids = select(User.id).where(User.organization_id == item.id) - db.query(RefreshSession).filter(RefreshSession.user_id.in_(session_ids), RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}, synchronize_session=False) + db.query(RefreshSession).filter( + RefreshSession.user_id.in_(session_ids), RefreshSession.revoked_at.is_(None) + ).update({"revoked_at": utcnow()}, synchronize_session=False) add_audit(db, user, f"organization.{target}", item.name, item.id) db.commit() return ok(organization_json(db, item), request) @router.post("/admin/organizations/{organization_id}/suspend") -def suspend_organization(organization_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def suspend_organization( + organization_id: str, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): return set_org_status(organization_id, "suspended", request, user, db) @router.post("/admin/organizations/{organization_id}/activate") -def activate_organization(organization_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def activate_organization( + organization_id: str, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): return set_org_status(organization_id, "active", request, user, db) @router.post("/admin/organizations/{organization_id}/schedule-deletion", status_code=202) -def schedule_organization_deletion(organization_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def schedule_organization_deletion( + organization_id: str, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(Organization, organization_id) if not item: raise HTTPException(status_code=404, detail="Organization was not found") item.status = "deletion_pending" correlation = request_id(request) - add_outbox(db, "fdx.v2.retention.cleanup.requested", "organization", item.id, {"resource_type": "organization", "resource_id": item.id}, item.id, correlation) + add_outbox( + db, + "fdx.v2.retention.cleanup.requested", + "organization", + item.id, + {"resource_type": "organization", "resource_id": item.id}, + item.id, + correlation, + ) add_audit(db, user, "organization.deletion_scheduled", item.name, item.id) db.commit() return ok({"status": "DELETION_PENDING"}, request) @router.get("/admin/organizations/{organization_id}/storage") -def organization_storage(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def organization_storage( + organization_id: str, + request: Request, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(Organization, organization_id) if not item: raise HTTPException(status_code=404, detail="Organization was not found") - reserved = db.scalar(select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where(StorageReservation.organization_id == item.id, StorageReservation.status == "RESERVED", StorageReservation.expires_at > utcnow())) or 0 - return ok({"storage_limit_bytes": item.storage_limit_bytes, "storage_used_bytes": item.storage_used_bytes, "storage_reserved_bytes": reserved, "storage_available_bytes": max(0, item.storage_limit_bytes - item.storage_used_bytes - reserved)}, request) + reserved = ( + db.scalar( + select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where( + StorageReservation.organization_id == item.id, + StorageReservation.status == "RESERVED", + StorageReservation.expires_at > utcnow(), + ) + ) + or 0 + ) + return ok( + { + "storage_limit_bytes": item.storage_limit_bytes, + "storage_used_bytes": item.storage_used_bytes, + "storage_reserved_bytes": reserved, + "storage_available_bytes": max(0, item.storage_limit_bytes - item.storage_used_bytes - reserved), + }, + request, + ) class StoragePolicyInput(BaseModel): @@ -558,24 +781,53 @@ class StoragePolicyInput(BaseModel): @router.put("/admin/organizations/{organization_id}/storage") -def update_organization_storage(organization_id: str, payload: StoragePolicyInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def update_organization_storage( + organization_id: str, + payload: StoragePolicyInput, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(Organization, organization_id) if not item: raise HTTPException(status_code=404, detail="Organization was not found") if payload.storage_limit_bytes < item.storage_used_bytes: raise HTTPException(status_code=422, detail="Storage limit cannot be below current usage") item.storage_limit_bytes = payload.storage_limit_bytes - add_audit(db, user, "organization.storage_policy.updated", str(payload.storage_limit_bytes), item.id) + add_audit( + db, + user, + "organization.storage_policy.updated", + str(payload.storage_limit_bytes), + item.id, + ) db.commit() - return ok({"storage_limit_bytes": item.storage_limit_bytes, "storage_used_bytes": item.storage_used_bytes}, request) + return ok( + { + "storage_limit_bytes": item.storage_limit_bytes, + "storage_used_bytes": item.storage_used_bytes, + }, + request, + ) @router.get("/admin/organizations/{organization_id}/retention") -def organization_retention(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def organization_retention( + organization_id: str, + request: Request, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(Organization, organization_id) if not item: raise HTTPException(status_code=404, detail="Organization was not found") - return ok({"default_retention_days": item.retention_days, "account_expires_at": item.expires_at.isoformat() if item.expires_at else None}, request) + return ok( + { + "default_retention_days": item.retention_days, + "account_expires_at": item.expires_at.isoformat() if item.expires_at else None, + }, + request, + ) class RetentionPolicyInput(BaseModel): @@ -584,41 +836,88 @@ class RetentionPolicyInput(BaseModel): @router.put("/admin/organizations/{organization_id}/retention") -def update_organization_retention(organization_id: str, payload: RetentionPolicyInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def update_organization_retention( + organization_id: str, + payload: RetentionPolicyInput, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = db.get(Organization, organization_id) if not item: raise HTTPException(status_code=404, detail="Organization was not found") item.retention_days = payload.default_retention_days item.expires_at = payload.account_expires_at - add_audit(db, user, "organization.retention_policy.updated", f"{payload.default_retention_days} days", item.id) + add_audit( + db, + user, + "organization.retention_policy.updated", + f"{payload.default_retention_days} days", + item.id, + ) db.commit() - return ok({"default_retention_days": item.retention_days, "account_expires_at": item.expires_at.isoformat() if item.expires_at else None}, request) + return ok( + { + "default_retention_days": item.retention_days, + "account_expires_at": item.expires_at.isoformat() if item.expires_at else None, + }, + request, + ) @router.get("/admin/organizations/{organization_id}/users") -def organization_users(organization_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def organization_users( + organization_id: str, + request: Request, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): if not db.get(Organization, organization_id): raise HTTPException(status_code=404, detail="Organization was not found") - rows = db.scalars(select(User).where(User.organization_id == organization_id).order_by(User.created_at.desc())).all() + rows = db.scalars( + select(User).where(User.organization_id == organization_id).order_by(User.created_at.desc()) + ).all() return ok([user_v2(row) for row in rows], request) @router.post("/admin/organizations/{organization_id}/users", status_code=201) -def invite_organization_user(organization_id: str, payload: InviteUserInput, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def invite_organization_user( + organization_id: str, + payload: InviteUserInput, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): organization = db.get(Organization, organization_id) if not organization: raise HTTPException(status_code=404, detail="Organization was not found") email = str(payload.email).lower() if find_user_by_email(db, email): raise HTTPException(status_code=409, detail="Email is already registered") - invited = User(organization_id=organization.id, name=payload.name.strip(), email=email, role=UserRole.ORG_ADMIN, status="invited") + invited = User( + organization_id=organization.id, + name=payload.name.strip(), + email=email, + role=UserRole.ORG_ADMIN, + status="invited", + ) db.add(invited) db.flush() raw_token, token_hash = new_opaque_token() - invitation = UserInvitation(user_id=invited.id, token_hash=token_hash, expires_at=utcnow() + timedelta(hours=settings.invitation_token_hours)) + invitation = UserInvitation( + user_id=invited.id, + token_hash=token_hash, + expires_at=utcnow() + timedelta(hours=settings.invitation_token_hours), + ) db.add(invitation) url = f"{settings.frontend_url}/accept-invite/{raw_token}" - mail = queue_email(db, organization.id, invited.email, f"Join {organization.name} on FDX", f"

Set your password

") + mail = queue_email( + db, + organization.id, + invited.email, + f"Join {organization.name} on FDX", + f"

Set your password

", + ) dispatch_email(db, mail) add_audit(db, user, "user.invited", invited.email, organization.id) db.commit() @@ -636,7 +935,12 @@ def admin_user_or_404(db: Session, user_id: str) -> User: @router.get("/admin/users/{user_id}") -def admin_user_detail(user_id: str, request: Request, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_user_detail( + user_id: str, + request: Request, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): return ok(user_v2(admin_user_or_404(db, user_id)), request) @@ -646,7 +950,13 @@ class UserUpdate(BaseModel): @router.patch("/admin/users/{user_id}") -def admin_update_user(user_id: str, payload: UserUpdate, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_update_user( + user_id: str, + payload: UserUpdate, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = admin_user_or_404(db, user_id) if payload.name is not None: item.name = payload.name.strip() @@ -661,33 +971,64 @@ def set_user_status(user_id: str, target: str, request: Request, actor: User, db item = admin_user_or_404(db, user_id) item.status = target if target != "active": - db.query(RefreshSession).filter(RefreshSession.user_id == item.id, RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}) + db.query(RefreshSession).filter(RefreshSession.user_id == item.id, RefreshSession.revoked_at.is_(None)).update( + {"revoked_at": utcnow()} + ) add_audit(db, actor, f"user.{target}", item.email, item.organization_id) db.commit() return ok(user_v2(item), request) @router.post("/admin/users/{user_id}/suspend") -def admin_suspend_user(user_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_suspend_user( + user_id: str, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): return set_user_status(user_id, "suspended", request, user, db) @router.post("/admin/users/{user_id}/activate") -def admin_activate_user(user_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_activate_user( + user_id: str, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): return set_user_status(user_id, "active", request, user, db) @router.post("/admin/users/{user_id}/resend-invite") -def admin_resend_invite(user_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_resend_invite( + user_id: str, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): item = admin_user_or_404(db, user_id) if item.password_hash: raise HTTPException(status_code=409, detail="User has already activated the account") - db.query(UserInvitation).filter(UserInvitation.user_id == item.id, UserInvitation.accepted_at.is_(None), UserInvitation.revoked_at.is_(None)).update({"revoked_at": utcnow()}) + db.query(UserInvitation).filter( + UserInvitation.user_id == item.id, + UserInvitation.accepted_at.is_(None), + UserInvitation.revoked_at.is_(None), + ).update({"revoked_at": utcnow()}) raw_token, token_hash = new_opaque_token() - invitation = UserInvitation(user_id=item.id, token_hash=token_hash, expires_at=utcnow() + timedelta(hours=settings.invitation_token_hours)) + invitation = UserInvitation( + user_id=item.id, + token_hash=token_hash, + expires_at=utcnow() + timedelta(hours=settings.invitation_token_hours), + ) db.add(invitation) url = f"{settings.frontend_url}/accept-invite/{raw_token}" - mail = queue_email(db, item.organization_id, item.email, "Your FDX invitation", f"

Set your password

") + mail = queue_email( + db, + item.organization_id, + item.email, + "Your FDX invitation", + f"

Set your password

", + ) dispatch_email(db, mail) add_audit(db, user, "user.invitation.resent", item.email, item.organization_id) db.commit() @@ -698,71 +1039,243 @@ def admin_resend_invite(user_id: str, request: Request, user: User = Depends(req @router.get("/admin/jobs") -def admin_jobs(request: Request, page: int = 1, page_size: int = 50, status: str | None = None, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_jobs( + request: Request, + page: int = 1, + page_size: int = 50, + status: str | None = None, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): offset, limit = pagination(page, page_size) filters = [ProcessingJob.status == status] if status else [] total = db.scalar(select(func.count(ProcessingJob.id)).where(*filters)) or 0 - rows = db.scalars(select(ProcessingJob).where(*filters).order_by(ProcessingJob.created_at.desc()).offset(offset).limit(limit)).all() - data = [{"id": row.id, "organization_id": row.organization_id, "event_id": row.event_id, "job_type": row.job_type, "status": row.status, "attempt": row.attempt, "max_attempts": row.max_attempts, "progress_current": row.progress_current, "progress_total": row.progress_total, "error": row.error} for row in rows] + rows = db.scalars( + select(ProcessingJob).where(*filters).order_by(ProcessingJob.created_at.desc()).offset(offset).limit(limit) + ).all() + data = [ + { + "id": row.id, + "organization_id": row.organization_id, + "event_id": row.event_id, + "job_type": row.job_type, + "status": row.status, + "attempt": row.attempt, + "max_attempts": row.max_attempts, + "progress_current": row.progress_current, + "progress_total": row.progress_total, + "error": row.error, + } + for row in rows + ] return ok(data, request, page=page, page_size=page_size, total=total) +@router.get("/admin/jobs/{job_id}") +def admin_job_detail( + job_id: str, + request: Request, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): + row = db.get(ProcessingJob, job_id) + if not row: + raise HTTPException(status_code=404, detail="Processing job was not found") + return ok( + { + "id": row.id, + "organization_id": row.organization_id, + "event_id": row.event_id, + "photo_id": row.photo_id, + "job_type": row.job_type, + "status": row.status, + "attempt": row.attempt, + "max_attempts": row.max_attempts, + "progress_current": row.progress_current, + "progress_total": row.progress_total, + "correlation_id": row.correlation_id, + "worker": row.worker, + "error": row.error, + "heartbeat_at": row.heartbeat_at.isoformat() if row.heartbeat_at else None, + "created_at": row.created_at.isoformat(), + "started_at": row.started_at.isoformat() if row.started_at else None, + "finished_at": row.completed_at.isoformat() if row.completed_at else None, + }, + request, + ) + + @router.post("/admin/jobs/{job_id}/retry", status_code=202) -def admin_retry_job(job_id: str, request: Request, user: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_retry_job( + job_id: str, + request: Request, + user: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): job = db.get(ProcessingJob, job_id) if not job or job.status not in {"failed", "FAILED", "DEAD_LETTERED"}: raise HTTPException(status_code=409, detail="Job is not retryable") job.status = "queued" job.error = None job.next_attempt_at = utcnow() - add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": job.photo_id}, job.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.ml.process.requested", + "processing_job", + job.id, + {"job_id": job.id, "media_id": job.photo_id}, + job.organization_id, + request_id(request), + ) add_audit(db, user, "processing.retry", job.id, job.organization_id) db.commit() return ok({"job_id": job.id, "status": "QUEUED"}, request) @router.get("/admin/logs") -def admin_logs(request: Request, page: int = 1, page_size: int = 50, _: User = Depends(require_super_admin), db: Session = Depends(get_db)): +def admin_logs( + request: Request, + page: int = 1, + page_size: int = 50, + _: User = Depends(require_super_admin), + db: Session = Depends(get_db), +): offset, limit = pagination(page, page_size) total = db.scalar(select(func.count(AuditLog.id))) or 0 rows = db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)).all() - return ok([{"id": row.id, "organization_id": row.organization_id, "actor": row.actor, "action": row.action, "details": row.details, "level": row.level, "created_at": row.created_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + return ok( + [ + { + "id": row.id, + "organization_id": row.organization_id, + "actor": row.actor, + "action": row.action, + "details": row.details, + "level": row.level, + "created_at": row.created_at.isoformat(), + } + for row in rows + ], + request, + page=page, + page_size=page_size, + total=total, + ) @router.get("/organization") -def current_organization(request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def current_organization( + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): return ok(organization_json(db, user.organization), request) @router.get("/organization/dashboard") -def organization_dashboard(request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): - events = db.scalars(select(Event).where(Event.organization_id == user.organization_id).order_by(Event.created_at.desc())).all() +def organization_dashboard( + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): + events = db.scalars( + select(Event).where(Event.organization_id == user.organization_id).order_by(Event.created_at.desc()) + ).all() data = { "organization": organization_json(db, user.organization), "events": [event_json(db, event) for event in events], - "participants": db.scalar(select(func.count(Participant.id)).where(Participant.organization_id == user.organization_id)) or 0, + "participants": db.scalar( + select(func.count(Participant.id)).where(Participant.organization_id == user.organization_id) + ) + or 0, "photos": db.scalar(select(func.count(Photo.id)).where(Photo.organization_id == user.organization_id)) or 0, - "failed_jobs": db.scalar(select(func.count(ProcessingJob.id)).where(ProcessingJob.organization_id == user.organization_id, ProcessingJob.status.in_(["failed", "FAILED", "DEAD_LETTERED"]))) or 0, + "failed_jobs": db.scalar( + select(func.count(ProcessingJob.id)).where( + ProcessingJob.organization_id == user.organization_id, + ProcessingJob.status.in_(["failed", "FAILED", "DEAD_LETTERED"]), + ) + ) + or 0, } return ok(data, request) @router.get("/organization/usage") -def organization_usage(request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): - reserved = db.scalar(select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where(StorageReservation.organization_id == user.organization_id, StorageReservation.status == "RESERVED", StorageReservation.expires_at > utcnow())) or 0 - return ok({"used_bytes": user.organization.storage_used_bytes, "reserved_bytes": reserved, "limit_bytes": user.organization.storage_limit_bytes, "available_bytes": max(0, user.organization.storage_limit_bytes - user.organization.storage_used_bytes - reserved)}, request) +def organization_usage( + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): + reserved = ( + db.scalar( + select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where( + StorageReservation.organization_id == user.organization_id, + StorageReservation.status == "RESERVED", + StorageReservation.expires_at > utcnow(), + ) + ) + or 0 + ) + return ok( + { + "used_bytes": user.organization.storage_used_bytes, + "reserved_bytes": reserved, + "limit_bytes": user.organization.storage_limit_bytes, + "available_bytes": max( + 0, + user.organization.storage_limit_bytes - user.organization.storage_used_bytes - reserved, + ), + }, + request, + ) @router.get("/organization/logs") -def organization_logs(request: Request, page: int = 1, page_size: int = 50, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def organization_logs( + request: Request, + page: int = 1, + page_size: int = 50, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): offset, limit = pagination(page, page_size) total = db.scalar(select(func.count(AuditLog.id)).where(AuditLog.organization_id == user.organization_id)) or 0 - rows = db.scalars(select(AuditLog).where(AuditLog.organization_id == user.organization_id).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)).all() - return ok([{"id": row.id, "actor": row.actor, "action": row.action, "details": row.details, "level": row.level, "created_at": row.created_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + rows = db.scalars( + select(AuditLog) + .where(AuditLog.organization_id == user.organization_id) + .order_by(AuditLog.created_at.desc()) + .offset(offset) + .limit(limit) + ).all() + return ok( + [ + { + "id": row.id, + "actor": row.actor, + "action": row.action, + "details": row.details, + "level": row.level, + "created_at": row.created_at.isoformat(), + } + for row in rows + ], + request, + page=page, + page_size=page_size, + total=total, + ) @router.get("/events") -def list_events(request: Request, page: int = 1, page_size: int = 50, status: str | None = None, search: str | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def list_events( + request: Request, + page: int = 1, + page_size: int = 50, + status: str | None = None, + search: str | None = None, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): offset, limit = pagination(page, page_size) filters = [Event.organization_id == user.organization_id] if status: @@ -771,12 +1284,26 @@ def list_events(request: Request, page: int = 1, page_size: int = 50, status: st filters.append(Event.name.ilike(f"%{search.strip()}%")) total = db.scalar(select(func.count(Event.id)).where(*filters)) or 0 rows = db.scalars(select(Event).where(*filters).order_by(Event.created_at.desc()).offset(offset).limit(limit)).all() - return ok([event_json(db, row) for row in rows], request, page=page, page_size=page_size, total=total) + return ok( + [event_json(db, row) for row in rows], + request, + page=page, + page_size=page_size, + total=total, + ) @router.post("/events", status_code=201) -def create_event(payload: EventInput, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): - retention = min(payload.retention_days or user.organization.retention_days, user.organization.retention_days) +def create_event( + payload: EventInput, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): + retention = min( + payload.retention_days or user.organization.retention_days, + user.organization.retention_days, + ) expires_at = (payload.starts_at + timedelta(days=retention)).date() item = Event( organization_id=user.organization_id, @@ -799,19 +1326,33 @@ def create_event(payload: EventInput, request: Request, user: User = Depends(req db.flush() except IntegrityError as exc: db.rollback() - raise HTTPException(status_code=409, detail="An event with the same name and date already exists") from exc + raise HTTPException( + status_code=409, + detail="An event with the same name and date already exists", + ) from exc add_audit(db, user, "event.created", item.name) db.commit() return ok(event_json(db, item), request) @router.get("/events/{event_id}") -def get_event(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def get_event( + event_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): return ok(event_json(db, tenant_event(db, user, event_id)), request) @router.patch("/events/{event_id}") -def update_event(event_id: str, payload: EventUpdate, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def update_event( + event_id: str, + payload: EventUpdate, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): item = tenant_event(db, user, event_id, lock=True) values = payload.model_dump(exclude_unset=True) if "retention_days" in values: @@ -820,7 +1361,9 @@ def update_event(event_id: str, payload: EventUpdate, request: Request, user: Us setattr(item, key, value) if payload.starts_at: item.event_date = payload.starts_at.date() - item.expires_at = (item.starts_at or datetime.combine(item.event_date, datetime.min.time(), timezone.utc)).date() + timedelta(days=item.retention_days) + item.expires_at = ( + item.starts_at or datetime.combine(item.event_date, datetime.min.time(), timezone.utc) + ).date() + timedelta(days=item.retention_days) add_audit(db, user, "event.updated", f"{item.name}: {', '.join(values)}") db.commit() return ok(event_json(db, item), request) @@ -830,7 +1373,10 @@ def transition_event(event_id: str, target: str, request: Request, user: User, d item = tenant_event(db, user, event_id, lock=True) current = item.status.upper() if target not in EVENT_TRANSITIONS.get(current, set()): - raise HTTPException(status_code=409, detail=f"Event cannot transition from {current} to {target}") + raise HTTPException( + status_code=409, + detail=f"Event cannot transition from {current} to {target}", + ) item.status = target add_audit(db, user, "event.state_changed", f"{current} -> {target}") db.commit() @@ -838,25 +1384,53 @@ def transition_event(event_id: str, target: str, request: Request, user: User, d @router.post("/events/{event_id}/open-enrollment") -def open_enrollment(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def open_enrollment( + event_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): return transition_event(event_id, "ENROLLMENT_OPEN", request, user, db) @router.post("/events/{event_id}/close-enrollment") -def close_enrollment(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def close_enrollment( + event_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): return transition_event(event_id, "READY_FOR_UPLOAD", request, user, db) @router.post("/events/{event_id}/archive") -def archive_event(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def archive_event( + event_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): return transition_event(event_id, "ARCHIVED", request, user, db) @router.delete("/events/{event_id}", status_code=202) -def delete_event(event_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def delete_event( + event_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): item = tenant_event(db, user, event_id, lock=True) item.status = "DELETION_PENDING" - add_outbox(db, "fdx.v2.retention.cleanup.requested", "event", item.id, {"resource_type": "event", "resource_id": item.id}, user.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.retention.cleanup.requested", + "event", + item.id, + {"resource_type": "event", "resource_id": item.id}, + user.organization_id, + request_id(request), + ) add_audit(db, user, "event.deletion_scheduled", item.name) db.commit() return ok({"id": item.id, "status": "DELETION_PENDING"}, request) @@ -884,7 +1458,10 @@ def parse_participants(content: bytes, filename: str) -> tuple[list[dict], list[ valid, errors, seen = [], [], set() for index, raw in enumerate(rows, start=2): normalized = {str(key).strip().lower(): value for key, value in raw.items()} - name, email = str(normalized.get("name") or "").strip(), str(normalized.get("email") or "").strip().lower() + name, email = ( + str(normalized.get("name") or "").strip(), + str(normalized.get("email") or "").strip().lower(), + ) row_errors = [] if not name: row_errors.append("name is required") @@ -901,13 +1478,26 @@ def parse_participants(content: bytes, filename: str) -> tuple[list[dict], list[ @router.post("/events/{event_id}/participant-imports", status_code=201) -def create_participant_import(event_id: str, request: Request, file: UploadFile = File(...), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def create_participant_import( + event_id: str, + request: Request, + file: UploadFile = File(...), + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id) content = file.file.read() if not content: raise HTTPException(status_code=422, detail="Import file is empty") valid, errors, total = parse_participants(content, file.filename or "participants.csv") - existing = set(db.scalars(select(Participant.email).where(Participant.event_id == event.id, Participant.email.in_([row["email"] for row in valid]))).all()) + existing = set( + db.scalars( + select(Participant.email).where( + Participant.event_id == event.id, + Participant.email.in_([row["email"] for row in valid]), + ) + ).all() + ) duplicates = [row for row in valid if row["email"] in existing] accepted = [row for row in valid if row["email"] not in existing] item = ParticipantImport( @@ -925,34 +1515,116 @@ def create_participant_import(event_id: str, request: Request, file: UploadFile ) db.add(item) db.flush() - add_audit(db, user, "participant_import.validated", f"{item.id}: {len(accepted)} valid, {len(errors)} invalid, {len(duplicates)} duplicate") + add_audit( + db, + user, + "participant_import.validated", + f"{item.id}: {len(accepted)} valid, {len(errors)} invalid, {len(duplicates)} duplicate", + ) db.commit() - return ok({"id": item.id, "status": item.status, "total_rows": total, "valid_rows": len(accepted), "invalid_rows": len(errors), "duplicate_rows": len(duplicates), "errors": errors, "duplicates": duplicates}, request) + return ok( + { + "id": item.id, + "status": item.status, + "total_rows": total, + "valid_rows": len(accepted), + "invalid_rows": len(errors), + "duplicate_rows": len(duplicates), + "errors": errors, + "duplicates": duplicates, + }, + request, + ) @router.get("/events/{event_id}/participant-imports") -def list_participant_imports(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def list_participant_imports( + event_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - rows = db.scalars(select(ParticipantImport).where(ParticipantImport.event_id == event_id, ParticipantImport.organization_id == user.organization_id).order_by(ParticipantImport.created_at.desc())).all() - return ok([{"id": row.id, "filename": row.source_filename, "status": row.status, "total_rows": row.total_rows, "valid_rows": row.valid_rows, "invalid_rows": row.invalid_rows, "duplicate_rows": row.duplicate_rows, "created_at": row.created_at.isoformat()} for row in rows], request) + rows = db.scalars( + select(ParticipantImport) + .where( + ParticipantImport.event_id == event_id, + ParticipantImport.organization_id == user.organization_id, + ) + .order_by(ParticipantImport.created_at.desc()) + ).all() + return ok( + [ + { + "id": row.id, + "filename": row.source_filename, + "status": row.status, + "total_rows": row.total_rows, + "valid_rows": row.valid_rows, + "invalid_rows": row.invalid_rows, + "duplicate_rows": row.duplicate_rows, + "created_at": row.created_at.isoformat(), + } + for row in rows + ], + request, + ) @router.get("/events/{event_id}/participant-imports/{import_id}") -def get_participant_import(event_id: str, import_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def get_participant_import( + event_id: str, + import_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - row = db.scalar(select(ParticipantImport).where(ParticipantImport.id == import_id, ParticipantImport.event_id == event_id, ParticipantImport.organization_id == user.organization_id)) + row = db.scalar( + select(ParticipantImport).where( + ParticipantImport.id == import_id, + ParticipantImport.event_id == event_id, + ParticipantImport.organization_id == user.organization_id, + ) + ) if not row: raise HTTPException(status_code=404, detail="Participant import was not found") - return ok({"id": row.id, "status": row.status, "total_rows": row.total_rows, "valid_rows": row.valid_rows, "invalid_rows": row.invalid_rows, "duplicate_rows": row.duplicate_rows, "validation_report": row.validation_report}, request) + return ok( + { + "id": row.id, + "status": row.status, + "total_rows": row.total_rows, + "valid_rows": row.valid_rows, + "invalid_rows": row.invalid_rows, + "duplicate_rows": row.duplicate_rows, + "validation_report": row.validation_report, + }, + request, + ) @router.post("/events/{event_id}/participant-imports/{import_id}/confirm", status_code=201) -def confirm_participant_import(event_id: str, import_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def confirm_participant_import( + event_id: str, + import_id: str, + request: Request, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id) record = reserve_idempotency(db, user, idempotency_key, f"confirm-import:{import_id}") if record and record.response_body: return record.response_body - item = db.scalar(select(ParticipantImport).where(ParticipantImport.id == import_id, ParticipantImport.event_id == event.id, ParticipantImport.organization_id == user.organization_id).with_for_update()) + item = db.scalar( + select(ParticipantImport) + .where( + ParticipantImport.id == import_id, + ParticipantImport.event_id == event.id, + ParticipantImport.organization_id == user.organization_id, + ) + .with_for_update() + ) if not item: raise HTTPException(status_code=404, detail="Participant import was not found") if item.status == "CONFIRMED": @@ -960,20 +1632,53 @@ def confirm_participant_import(event_id: str, import_id: str, request: Request, created, invitations = [], [] for row in item.normalized_rows or []: raw_token, token_hash = new_opaque_token() - participant = Participant(organization_id=user.organization_id, event_id=event.id, name=row["name"], email=row["email"], enrollment_status="invited", delivery_status="pending", enrollment_token_hash=token_hash, enrollment_expires_at=utcnow() + timedelta(days=settings.enrollment_token_days)) + participant = Participant( + organization_id=user.organization_id, + event_id=event.id, + name=row["name"], + email=row["email"], + enrollment_status="invited", + delivery_status="pending", + enrollment_token_hash=token_hash, + enrollment_expires_at=utcnow() + timedelta(days=settings.enrollment_token_days), + ) db.add(participant) db.flush() - db.add(ParticipantEnrollmentToken(participant_id=participant.id, token_hash=token_hash, expires_at=participant.enrollment_expires_at)) + db.add( + ParticipantEnrollmentToken( + participant_id=participant.id, + token_hash=token_hash, + expires_at=participant.enrollment_expires_at, + ) + ) url = f"{settings.frontend_url}/enroll/{raw_token}" - mail = queue_email(db, user.organization_id, participant.email, f"Find your photos from {event.name}", f"

Find My Photos

") + mail = queue_email( + db, + user.organization_id, + participant.email, + f"Find your photos from {event.name}", + f"

Find My Photos

", + ) dispatch_email(db, mail) created.append(participant.id) if settings.environment == "development": invitations.append({"participant_id": participant.id, "url": url}) item.status = "CONFIRMED" item.confirmed_at = utcnow() - add_audit(db, user, "participant_import.confirmed", f"{item.id}: {len(created)} participants") - result = ok({"import_id": item.id, "participants_created": len(created), "development_invitations": invitations}, request) + add_audit( + db, + user, + "participant_import.confirmed", + f"{item.id}: {len(created)} participants", + ) + result = ok( + { + "import_id": item.id, + "participants_created": len(created), + "development_invitations": invitations, + }, + request, + ) if record: record.response_status = 201 record.response_body = result @@ -982,36 +1687,103 @@ def confirm_participant_import(event_id: str, import_id: str, request: Request, @router.get("/events/{event_id}/participants") -def participants(event_id: str, request: Request, page: int = 1, page_size: int = 50, status: str | None = None, search: str | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def participants( + event_id: str, + request: Request, + page: int = 1, + page_size: int = 50, + status: str | None = None, + search: str | None = None, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) offset, limit = pagination(page, page_size) - filters = [Participant.event_id == event_id, Participant.organization_id == user.organization_id] + filters = [ + Participant.event_id == event_id, + Participant.organization_id == user.organization_id, + ] if status: filters.append(Participant.enrollment_status == status) if search: filters.append((Participant.name.ilike(f"%{search}%")) | (Participant.email.ilike(f"%{search}%"))) total = db.scalar(select(func.count(Participant.id)).where(*filters)) or 0 - rows = db.scalars(select(Participant).where(*filters).order_by(Participant.created_at.desc()).offset(offset).limit(limit)).all() - return ok([{"id": row.id, "name": row.name, "email": row.email, "enrollment_status": row.enrollment_status, "delivery_status": row.delivery_status, "created_at": row.created_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + rows = db.scalars( + select(Participant).where(*filters).order_by(Participant.created_at.desc()).offset(offset).limit(limit) + ).all() + return ok( + [ + { + "id": row.id, + "name": row.name, + "email": row.email, + "enrollment_status": row.enrollment_status, + "delivery_status": row.delivery_status, + "created_at": row.created_at.isoformat(), + } + for row in rows + ], + request, + page=page, + page_size=page_size, + total=total, + ) def tenant_participant(db: Session, user: User, event_id: str, participant_id: str) -> Participant: tenant_event(db, user, event_id) - item = db.scalar(select(Participant).where(Participant.id == participant_id, Participant.event_id == event_id, Participant.organization_id == user.organization_id)) + item = db.scalar( + select(Participant).where( + Participant.id == participant_id, + Participant.event_id == event_id, + Participant.organization_id == user.organization_id, + ) + ) if not item: raise HTTPException(status_code=404, detail="Participant was not found") return item @router.get("/events/{event_id}/participants/{participant_id}") -def participant_detail(event_id: str, participant_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def participant_detail( + event_id: str, + participant_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): item = tenant_participant(db, user, event_id, participant_id) - matches = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.participant_id == item.id, FaceMatch.state.in_(["high", "approved"]))) or 0 - return ok({"id": item.id, "name": item.name, "email": item.email, "enrollment_status": item.enrollment_status, "delivery_status": item.delivery_status, "matches": matches}, request) + matches = ( + db.scalar( + select(func.count(FaceMatch.id)).where( + FaceMatch.participant_id == item.id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0 + ) + return ok( + { + "id": item.id, + "name": item.name, + "email": item.email, + "enrollment_status": item.enrollment_status, + "delivery_status": item.delivery_status, + "matches": matches, + }, + request, + ) @router.patch("/events/{event_id}/participants/{participant_id}") -def update_participant(event_id: str, participant_id: str, payload: ParticipantInput, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def update_participant( + event_id: str, + participant_id: str, + payload: ParticipantInput, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): item = tenant_participant(db, user, event_id, participant_id) item.name = payload.name.strip() item.email = str(payload.email).lower() @@ -1021,19 +1793,35 @@ def update_participant(event_id: str, participant_id: str, payload: ParticipantI @router.delete("/events/{event_id}/participants/{participant_id}", status_code=204) -def delete_participant(event_id: str, participant_id: str, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def delete_participant( + event_id: str, + participant_id: str, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): item = tenant_participant(db, user, event_id, participant_id) if item.enrollment: storage.delete(item.enrollment.storage_key) user.organization.storage_used_bytes = max(0, user.organization.storage_used_bytes - item.enrollment.size_bytes) - db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event_id, operation="DELETE", bytes=-item.enrollment.size_bytes)) + db.add( + StorageUsageLedger( + organization_id=user.organization_id, + event_id=event_id, + operation="DELETE", + bytes=-item.enrollment.size_bytes, + ) + ) add_audit(db, user, "participant.deleted", item.email) db.delete(item) db.commit() def send_participant_invite(db: Session, participant: Participant, user: User) -> str: - db.query(ParticipantEnrollmentToken).filter(ParticipantEnrollmentToken.participant_id == participant.id, ParticipantEnrollmentToken.consumed_at.is_(None), ParticipantEnrollmentToken.revoked_at.is_(None)).update({"revoked_at": utcnow()}) + db.query(ParticipantEnrollmentToken).filter( + ParticipantEnrollmentToken.participant_id == participant.id, + ParticipantEnrollmentToken.consumed_at.is_(None), + ParticipantEnrollmentToken.revoked_at.is_(None), + ).update({"revoked_at": utcnow()}) raw_token, token_hash = new_opaque_token() expires = utcnow() + timedelta(days=settings.enrollment_token_days) participant.enrollment_token_hash = token_hash @@ -1041,7 +1829,13 @@ def send_participant_invite(db: Session, participant: Participant, user: User) - participant.enrollment_status = "invited" db.add(ParticipantEnrollmentToken(participant_id=participant.id, token_hash=token_hash, expires_at=expires)) url = f"{settings.frontend_url}/enroll/{raw_token}" - mail = queue_email(db, participant.organization_id, participant.email, f"Find your photos from {participant.event.name}", f"

Find My Photos

") + mail = queue_email( + db, + participant.organization_id, + participant.email, + f"Find your photos from {participant.event.name}", + f"

Find My Photos

", + ) dispatch_email(db, mail) add_audit(db, user, "participant.invitation.sent", participant.email) return url @@ -1049,7 +1843,13 @@ def send_participant_invite(db: Session, participant: Participant, user: User) - @router.post("/events/{event_id}/participants/{participant_id}/send-invite") @router.post("/events/{event_id}/participants/{participant_id}/resend-invite") -def participant_send_invite(event_id: str, participant_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def participant_send_invite( + event_id: str, + participant_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): item = tenant_participant(db, user, event_id, participant_id) url = send_participant_invite(db, item, user) db.commit() @@ -1059,11 +1859,75 @@ def participant_send_invite(event_id: str, participant_id: str, request: Request return ok(data, request) +@router.post("/events/{event_id}/participants/send-invites", status_code=202) +def participant_send_invites( + event_id: str, + payload: BulkInvitationInput, + request: Request, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): + tenant_event(db, user, event_id) + body = json.dumps(payload.model_dump(), sort_keys=True) + idem = reserve_idempotency(db, user, idempotency_key, f"bulk-invite:{event_id}:{body}") + if idem and idem.response_body: + return idem.response_body + filters = [ + Participant.event_id == event_id, + Participant.organization_id == user.organization_id, + Participant.enrollment_status.in_(payload.enrollment_status), + ] + if payload.search: + term = f"%{payload.search.strip()}%" + filters.append((Participant.name.ilike(term)) | (Participant.email.ilike(term))) + rows = db.scalars(select(Participant).where(*filters).order_by(Participant.created_at).limit(10_000)).all() + development_urls = [] + for participant in rows: + url = send_participant_invite(db, participant, user) + if settings.environment == "development": + development_urls.append({"participant_id": participant.id, "url": url}) + result = ok( + { + "status": "QUEUED", + "invitations_queued": len(rows), + "development_invitations": development_urls, + }, + request, + ) + if idem: + idem.response_status = 202 + idem.response_body = result + add_audit( + db, + user, + "participant.invitation.bulk_queued", + f"{event_id}: {len(rows)} invitations", + ) + db.commit() + return result + + @router.post("/events/{event_id}/participants", status_code=201) -def create_participant(event_id: str, payload: ParticipantInput, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def create_participant( + event_id: str, + payload: ParticipantInput, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id) raw_token, token_hash = new_opaque_token() - item = Participant(organization_id=user.organization_id, event_id=event.id, name=payload.name.strip(), email=str(payload.email).lower(), enrollment_status="invited", delivery_status="pending", enrollment_token_hash=token_hash, enrollment_expires_at=utcnow() + timedelta(days=settings.enrollment_token_days)) + item = Participant( + organization_id=user.organization_id, + event_id=event.id, + name=payload.name.strip(), + email=str(payload.email).lower(), + enrollment_status="invited", + delivery_status="pending", + enrollment_token_hash=token_hash, + enrollment_expires_at=utcnow() + timedelta(days=settings.enrollment_token_days), + ) db.add(item) try: db.flush() @@ -1071,102 +1935,354 @@ def create_participant(event_id: str, payload: ParticipantInput, request: Reques db.rollback() raise HTTPException(status_code=409, detail="Participant email already exists in this event") from exc url = f"{settings.frontend_url}/enroll/{raw_token}" - db.add(ParticipantEnrollmentToken(participant_id=item.id, token_hash=token_hash, expires_at=item.enrollment_expires_at)) - mail = queue_email(db, user.organization_id, item.email, f"Find your photos from {event.name}", f"

Find My Photos

") + db.add( + ParticipantEnrollmentToken( + participant_id=item.id, + token_hash=token_hash, + expires_at=item.enrollment_expires_at, + ) + ) + mail = queue_email( + db, + user.organization_id, + item.email, + f"Find your photos from {event.name}", + f"

Find My Photos

", + ) dispatch_email(db, mail) add_audit(db, user, "participant.created", item.email) db.commit() - data = {"id": item.id, "name": item.name, "email": item.email, "enrollment_status": item.enrollment_status} + data = { + "id": item.id, + "name": item.name, + "email": item.email, + "enrollment_status": item.enrollment_status, + } if settings.environment == "development": data["development_enrollment_url"] = url return ok(data, request) @router.post("/events/{event_id}/upload-batches", status_code=201) -def create_upload_batch(event_id: str, payload: UploadBatchInput, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def create_upload_batch( + event_id: str, + payload: UploadBatchInput, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id, lock=True) - active_reserved = db.scalar(select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where(StorageReservation.organization_id == user.organization_id, StorageReservation.status == "RESERVED", StorageReservation.expires_at > utcnow())) or 0 + active_reserved = ( + db.scalar( + select(func.coalesce(func.sum(StorageReservation.bytes), 0)).where( + StorageReservation.organization_id == user.organization_id, + StorageReservation.status == "RESERVED", + StorageReservation.expires_at > utcnow(), + ) + ) + or 0 + ) if payload.reserved_bytes > settings.max_upload_bytes: raise HTTPException(status_code=413, detail="Upload batch exceeds the configured maximum") - if user.organization.storage_used_bytes + active_reserved + payload.reserved_bytes > user.organization.storage_limit_bytes: + if ( + user.organization.storage_used_bytes + active_reserved + payload.reserved_bytes + > user.organization.storage_limit_bytes + ): raise HTTPException(status_code=413, detail="Organization storage quota would be exceeded") - batch = UploadBatch(organization_id=user.organization_id, event_id=event.id, expected_files=payload.expected_files, reserved_bytes=payload.reserved_bytes, created_by=user.id, status="CREATED") + batch = UploadBatch( + organization_id=user.organization_id, + event_id=event.id, + expected_files=payload.expected_files, + reserved_bytes=payload.reserved_bytes, + created_by=user.id, + status="CREATED", + ) db.add(batch) db.flush() - reservation = StorageReservation(organization_id=user.organization_id, event_id=event.id, upload_batch_id=batch.id, bytes=payload.reserved_bytes, status="RESERVED", expires_at=utcnow() + timedelta(minutes=settings.upload_reservation_minutes)) + reservation = StorageReservation( + organization_id=user.organization_id, + event_id=event.id, + upload_batch_id=batch.id, + bytes=payload.reserved_bytes, + status="RESERVED", + expires_at=utcnow() + timedelta(minutes=settings.upload_reservation_minutes), + ) db.add(reservation) - db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event.id, operation="RESERVE", bytes=payload.reserved_bytes)) + db.add( + StorageUsageLedger( + organization_id=user.organization_id, + event_id=event.id, + operation="RESERVE", + bytes=payload.reserved_bytes, + ) + ) add_audit(db, user, "upload_batch.created", f"{batch.id}: {payload.reserved_bytes} bytes") db.commit() - return ok({"id": batch.id, "status": batch.status, "reserved_bytes": batch.reserved_bytes, "reservation_expires_at": reservation.expires_at.isoformat()}, request) + return ok( + { + "id": batch.id, + "status": batch.status, + "reserved_bytes": batch.reserved_bytes, + "reservation_expires_at": reservation.expires_at.isoformat(), + }, + request, + ) @router.get("/events/{event_id}/upload-batches") -def upload_batches(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def upload_batches( + event_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - rows = db.scalars(select(UploadBatch).where(UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id).order_by(UploadBatch.created_at.desc())).all() - return ok([{"id": row.id, "status": row.status, "expected_files": row.expected_files, "uploaded_files": row.uploaded_files, "reserved_bytes": row.reserved_bytes, "committed_bytes": row.committed_bytes, "created_at": row.created_at.isoformat(), "completed_at": row.completed_at.isoformat() if row.completed_at else None} for row in rows], request) + rows = db.scalars( + select(UploadBatch) + .where( + UploadBatch.event_id == event_id, + UploadBatch.organization_id == user.organization_id, + ) + .order_by(UploadBatch.created_at.desc()) + ).all() + return ok( + [ + { + "id": row.id, + "status": row.status, + "expected_files": row.expected_files, + "uploaded_files": row.uploaded_files, + "reserved_bytes": row.reserved_bytes, + "committed_bytes": row.committed_bytes, + "created_at": row.created_at.isoformat(), + "completed_at": row.completed_at.isoformat() if row.completed_at else None, + } + for row in rows + ], + request, + ) @router.get("/events/{event_id}/upload-batches/{batch_id}") -def upload_batch_detail(event_id: str, batch_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def upload_batch_detail( + event_id: str, + batch_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - row = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id)) + row = db.scalar( + select(UploadBatch).where( + UploadBatch.id == batch_id, + UploadBatch.event_id == event_id, + UploadBatch.organization_id == user.organization_id, + ) + ) if not row: raise HTTPException(status_code=404, detail="Upload batch was not found") - return ok({"id": row.id, "status": row.status, "expected_files": row.expected_files, "uploaded_files": row.uploaded_files, "reserved_bytes": row.reserved_bytes, "committed_bytes": row.committed_bytes, "manifest": row.manifest}, request) + return ok( + { + "id": row.id, + "status": row.status, + "expected_files": row.expected_files, + "uploaded_files": row.uploaded_files, + "reserved_bytes": row.reserved_bytes, + "committed_bytes": row.committed_bytes, + "manifest": row.manifest, + }, + request, + ) @router.post("/events/{event_id}/upload-batches/{batch_id}/presign") -def presign_uploads(event_id: str, batch_id: str, payload: PresignInput, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def presign_uploads( + event_id: str, + batch_id: str, + payload: PresignInput, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id).with_for_update()) + batch = db.scalar( + select(UploadBatch) + .where( + UploadBatch.id == batch_id, + UploadBatch.event_id == event_id, + UploadBatch.organization_id == user.organization_id, + ) + .with_for_update() + ) if not batch or batch.status not in {"CREATED", "UPLOADING"}: raise HTTPException(status_code=409, detail="Upload batch cannot accept files") + existing_manifest = list(batch.manifest or []) + existing_bytes = sum(item["size_bytes"] for item in existing_manifest) total = sum(item.size_bytes for item in payload.files) - if total > batch.reserved_bytes: + if existing_bytes + total > batch.reserved_bytes: raise HTTPException(status_code=413, detail="Files exceed reserved upload bytes") + if len(existing_manifest) + len(payload.files) > batch.expected_files: + raise HTTPException(status_code=409, detail="Files exceed the upload batch manifest count") + existing_hashes = {item["sha256"] for item in existing_manifest} manifest, urls = [], [] for item in payload.files: if item.content_type not in ALLOWED_IMAGE_TYPES: raise HTTPException(status_code=422, detail=f"Unsupported media type for {item.filename}") + if item.size_bytes > settings.max_media_file_bytes: + raise HTTPException(status_code=413, detail=f"Media file is too large: {item.filename}") + expected_extension = { + "image/jpeg": {".jpg", ".jpeg"}, + "image/png": {".png"}, + "image/webp": {".webp"}, + }[item.content_type] + suffix = "." + item.filename.lower().rsplit(".", 1)[-1] if "." in item.filename else "" + if suffix not in expected_extension: + raise HTTPException( + status_code=422, + detail=f"Filename extension does not match media type: {item.filename}", + ) + if item.sha256.lower() in existing_hashes: + raise HTTPException( + status_code=409, + detail=f"Duplicate file in upload batch: {item.filename}", + ) media_id = str(uuid.uuid4()) extension = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[item.content_type] key = f"organizations/{user.organization_id}/events/{event_id}/media/original/{media_id}{extension}" - upload_url = storage.presign_put(key, item.content_type) - if not upload_url: - upload_url = f"/api/v2/events/{event_id}/upload-batches/{batch.id}/objects/{media_id}" - record = {"media_id": media_id, "filename": item.filename, "content_type": item.content_type, "size_bytes": item.size_bytes, "sha256": item.sha256.lower(), "storage_key": key} + record = { + "media_id": media_id, + "filename": item.filename, + "content_type": item.content_type, + "size_bytes": item.size_bytes, + "sha256": item.sha256.lower(), + "storage_key": key, + } + multipart = ( + storage.create_multipart_upload(key, item.content_type, item.size_bytes, settings.multipart_part_bytes) + if item.size_bytes >= settings.multipart_threshold_bytes + else None + ) + if multipart: + record["multipart_upload_id"] = multipart["upload_id"] + urls.append( + { + **record, + "multipart": True, + "part_size": multipart["part_size"], + "parts": multipart["parts"], + "complete_url": f"/v2/events/{event_id}/upload-batches/{batch.id}/objects/{media_id}/complete-multipart", + } + ) + else: + upload_url = storage.presign_put(key, item.content_type) + if not upload_url: + upload_url = f"/api/v2/events/{event_id}/upload-batches/{batch.id}/objects/{media_id}" + urls.append( + { + **record, + "multipart": False, + "upload_url": upload_url, + "method": "PUT", + "headers": {"Content-Type": item.content_type}, + } + ) manifest.append(record) - urls.append({**record, "upload_url": upload_url, "method": "PUT", "headers": {"Content-Type": item.content_type}}) - batch.manifest = manifest + existing_hashes.add(item.sha256.lower()) + batch.manifest = [*existing_manifest, *manifest] batch.status = "UPLOADING" db.commit() return ok({"batch_id": batch.id, "files": urls, "expires_in": 900}, request) @router.put("/events/{event_id}/upload-batches/{batch_id}/objects/{media_id}", status_code=204) -async def local_upload_object(event_id: str, batch_id: str, media_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +async def local_upload_object( + event_id: str, + batch_id: str, + media_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): if settings.storage_backend == "s3": raise HTTPException(status_code=404, detail="Direct local upload endpoint is disabled") tenant_event(db, user, event_id) - batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id)) + batch = db.scalar( + select(UploadBatch).where( + UploadBatch.id == batch_id, + UploadBatch.event_id == event_id, + UploadBatch.organization_id == user.organization_id, + ) + ) record = next((row for row in batch.manifest or [] if row["media_id"] == media_id), None) if batch else None if not record: raise HTTPException(status_code=404, detail="Upload object was not found") content = await request.body() if len(content) != record["size_bytes"] or hashlib.sha256(content).hexdigest() != record["sha256"]: - raise HTTPException(status_code=422, detail="Uploaded object size or checksum does not match manifest") + raise HTTPException( + status_code=422, + detail="Uploaded object size or checksum does not match manifest", + ) storage.put(record["storage_key"], content, record["content_type"]) +@router.post("/events/{event_id}/upload-batches/{batch_id}/objects/{media_id}/complete-multipart") +def complete_multipart_upload( + event_id: str, + batch_id: str, + media_id: str, + payload: CompleteMultipartInput, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): + tenant_event(db, user, event_id) + batch = db.scalar( + select(UploadBatch) + .where( + UploadBatch.id == batch_id, + UploadBatch.event_id == event_id, + UploadBatch.organization_id == user.organization_id, + ) + .with_for_update() + ) + record = next((row for row in batch.manifest or [] if row["media_id"] == media_id), None) if batch else None + if not record or not record.get("multipart_upload_id"): + raise HTTPException(status_code=404, detail="Multipart upload was not found") + if record["multipart_upload_id"] != payload.upload_id: + raise HTTPException(status_code=409, detail="Multipart upload ID does not match the manifest") + storage.complete_multipart_upload( + record["storage_key"], + payload.upload_id, + [part.model_dump() for part in payload.parts], + ) + record["multipart_completed"] = True + batch.manifest = list(batch.manifest) + db.commit() + return ok({"media_id": media_id, "status": "UPLOADED"}, request) + + @router.post("/events/{event_id}/upload-batches/{batch_id}/complete", status_code=202) -def complete_upload_batch(event_id: str, batch_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def complete_upload_batch( + event_id: str, + batch_id: str, + request: Request, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id, lock=True) idem = reserve_idempotency(db, user, idempotency_key, f"complete-upload:{batch_id}") if idem and idem.response_body: return idem.response_body - batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event.id, UploadBatch.organization_id == user.organization_id).with_for_update()) + batch = db.scalar( + select(UploadBatch) + .where( + UploadBatch.id == batch_id, + UploadBatch.event_id == event.id, + UploadBatch.organization_id == user.organization_id, + ) + .with_for_update() + ) if not batch or batch.status not in {"UPLOADING", "VERIFYING"}: raise HTTPException(status_code=409, detail="Upload batch cannot be completed") batch.status = "VERIFYING" @@ -1181,15 +2297,51 @@ def complete_upload_batch(event_id: str, batch_id: str, request: Request, idempo if db.scalar(select(Photo.id).where(Photo.event_id == event.id, Photo.sha256 == record["sha256"])): storage.delete(record["storage_key"]) continue - photo = Photo(id=record["media_id"], organization_id=user.organization_id, event_id=event.id, filename=record["filename"], storage_key=record["storage_key"], content_type=record["content_type"], size_bytes=record["size_bytes"], sha256=record["sha256"], processing_status="queued") - job = ProcessingJob(organization_id=user.organization_id, event_id=event.id, photo_id=photo.id, job_type="ML_PROCESS", status="queued", correlation_id=request_id(request), max_attempts=5) + photo = Photo( + id=record["media_id"], + organization_id=user.organization_id, + event_id=event.id, + filename=record["filename"], + storage_key=record["storage_key"], + content_type=record["content_type"], + size_bytes=record["size_bytes"], + sha256=record["sha256"], + processing_status="queued", + ) + job = ProcessingJob( + organization_id=user.organization_id, + event_id=event.id, + photo_id=photo.id, + job_type="ML_PROCESS", + status="queued", + correlation_id=request_id(request), + max_attempts=5, + ) db.add_all([photo, job]) db.flush() - add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": photo.id}, user.organization_id, request_id(request)) - db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event.id, photo_id=photo.id, operation="ADD", bytes=photo.size_bytes)) + add_outbox( + db, + "fdx.v2.ml.process.requested", + "processing_job", + job.id, + {"job_id": job.id, "media_id": photo.id}, + user.organization_id, + request_id(request), + ) + db.add( + StorageUsageLedger( + organization_id=user.organization_id, + event_id=event.id, + photo_id=photo.id, + operation="ADD", + bytes=photo.size_bytes, + ) + ) committed += photo.size_bytes jobs.append(job.id) - reservation = db.scalar(select(StorageReservation).where(StorageReservation.upload_batch_id == batch.id).with_for_update()) + reservation = db.scalar( + select(StorageReservation).where(StorageReservation.upload_batch_id == batch.id).with_for_update() + ) if reservation: reservation.status = "COMMITTED" batch.committed_bytes = committed @@ -1198,8 +2350,21 @@ def complete_upload_batch(event_id: str, batch_id: str, request: Request, idempo batch.completed_at = utcnow() user.organization.storage_used_bytes += committed event.status = "PROCESSING" - add_audit(db, user, "upload_batch.completed", f"{batch.id}: {len(jobs)} media, {committed} bytes") - result = ok({"batch_id": batch.id, "status": batch.status, "media_created": len(jobs), "jobs": jobs}, request) + add_audit( + db, + user, + "upload_batch.completed", + f"{batch.id}: {len(jobs)} media, {committed} bytes", + ) + result = ok( + { + "batch_id": batch.id, + "status": batch.status, + "media_created": len(jobs), + "jobs": jobs, + }, + request, + ) if idem: idem.response_status = 202 idem.response_body = result @@ -1208,17 +2373,40 @@ def complete_upload_batch(event_id: str, batch_id: str, request: Request, idempo @router.post("/events/{event_id}/upload-batches/{batch_id}/cancel") -def cancel_upload_batch(event_id: str, batch_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def cancel_upload_batch( + event_id: str, + batch_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - batch = db.scalar(select(UploadBatch).where(UploadBatch.id == batch_id, UploadBatch.event_id == event_id, UploadBatch.organization_id == user.organization_id).with_for_update()) + batch = db.scalar( + select(UploadBatch) + .where( + UploadBatch.id == batch_id, + UploadBatch.event_id == event_id, + UploadBatch.organization_id == user.organization_id, + ) + .with_for_update() + ) if not batch or batch.status == "COMPLETE": raise HTTPException(status_code=409, detail="Upload batch cannot be cancelled") for record in batch.manifest or []: + if record.get("multipart_upload_id") and not record.get("multipart_completed"): + storage.abort_multipart_upload(record["storage_key"], record["multipart_upload_id"]) storage.delete(record["storage_key"]) reservation = db.scalar(select(StorageReservation).where(StorageReservation.upload_batch_id == batch.id)) if reservation: reservation.status = "RELEASED" - db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event_id, operation="RELEASE", bytes=reservation.bytes)) + db.add( + StorageUsageLedger( + organization_id=user.organization_id, + event_id=event_id, + operation="RELEASE", + bytes=reservation.bytes, + ) + ) batch.status = "CANCELLED" add_audit(db, user, "upload_batch.cancelled", batch.id) db.commit() @@ -1226,49 +2414,143 @@ def cancel_upload_batch(event_id: str, batch_id: str, request: Request, user: Us @router.get("/events/{event_id}/media") -def event_media(event_id: str, request: Request, page: int = 1, page_size: int = 50, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def event_media( + event_id: str, + request: Request, + page: int = 1, + page_size: int = 50, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) offset, limit = pagination(page, page_size) - total = db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event_id, Photo.organization_id == user.organization_id)) or 0 - rows = db.scalars(select(Photo).where(Photo.event_id == event_id, Photo.organization_id == user.organization_id).order_by(Photo.uploaded_at.desc()).offset(offset).limit(limit)).all() - return ok([{"id": row.id, "filename": row.filename, "mime_type": row.content_type, "size_bytes": row.size_bytes, "sha256": row.sha256, "status": row.processing_status, "uploaded_at": row.uploaded_at.isoformat()} for row in rows], request, page=page, page_size=page_size, total=total) + total = ( + db.scalar( + select(func.count(Photo.id)).where( + Photo.event_id == event_id, + Photo.organization_id == user.organization_id, + ) + ) + or 0 + ) + rows = db.scalars( + select(Photo) + .where(Photo.event_id == event_id, Photo.organization_id == user.organization_id) + .order_by(Photo.uploaded_at.desc()) + .offset(offset) + .limit(limit) + ).all() + return ok( + [ + { + "id": row.id, + "filename": row.filename, + "mime_type": row.content_type, + "size_bytes": row.size_bytes, + "sha256": row.sha256, + "status": row.processing_status, + "uploaded_at": row.uploaded_at.isoformat(), + } + for row in rows + ], + request, + page=page, + page_size=page_size, + total=total, + ) def tenant_photo(db: Session, user: User, event_id: str, media_id: str) -> Photo: tenant_event(db, user, event_id) - item = db.scalar(select(Photo).where(Photo.id == media_id, Photo.event_id == event_id, Photo.organization_id == user.organization_id)) + item = db.scalar( + select(Photo).where( + Photo.id == media_id, + Photo.event_id == event_id, + Photo.organization_id == user.organization_id, + ) + ) if not item: raise HTTPException(status_code=404, detail="Media was not found") return item @router.get("/events/{event_id}/media/{media_id}") -def media_detail(event_id: str, media_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def media_detail( + event_id: str, + media_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): item = tenant_photo(db, user, event_id, media_id) - return ok({"id": item.id, "filename": item.filename, "mime_type": item.content_type, "size_bytes": item.size_bytes, "sha256": item.sha256, "status": item.processing_status, "download_url": storage.presign_get(item.storage_key) or f"/api/media/{item.id}"}, request) + return ok( + { + "id": item.id, + "filename": item.filename, + "mime_type": item.content_type, + "size_bytes": item.size_bytes, + "sha256": item.sha256, + "status": item.processing_status, + "download_url": storage.presign_get(item.storage_key) or f"/api/media/{item.id}", + }, + request, + ) @router.delete("/events/{event_id}/media/{media_id}", status_code=204) -def delete_media(event_id: str, media_id: str, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def delete_media( + event_id: str, + media_id: str, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): item = tenant_photo(db, user, event_id, media_id) released = item.size_bytes + item.thumbnail_size_bytes storage.delete(item.storage_key) if item.thumbnail_storage_key: storage.delete(item.thumbnail_storage_key) user.organization.storage_used_bytes = max(0, user.organization.storage_used_bytes - released) - db.add(StorageUsageLedger(organization_id=user.organization_id, event_id=event_id, operation="DELETE", bytes=-released)) + db.add( + StorageUsageLedger( + organization_id=user.organization_id, + event_id=event_id, + operation="DELETE", + bytes=-released, + ) + ) add_audit(db, user, "media.deleted", item.filename) db.delete(item) db.commit() @router.post("/events/{event_id}/media/{media_id}/reprocess", status_code=202) -def reprocess_media(event_id: str, media_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def reprocess_media( + event_id: str, + media_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): item = tenant_photo(db, user, event_id, media_id) - job = ProcessingJob(organization_id=user.organization_id, event_id=event_id, photo_id=item.id, job_type="ML_PROCESS", status="queued", correlation_id=request_id(request)) + job = ProcessingJob( + organization_id=user.organization_id, + event_id=event_id, + photo_id=item.id, + job_type="ML_PROCESS", + status="queued", + correlation_id=request_id(request), + ) db.add(job) db.flush() - add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": item.id}, user.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.ml.process.requested", + "processing_job", + job.id, + {"job_id": job.id, "media_id": item.id}, + user.organization_id, + request_id(request), + ) item.processing_status = "queued" add_audit(db, user, "media.reprocess_requested", item.filename) db.commit() @@ -1276,19 +2558,41 @@ def reprocess_media(event_id: str, media_id: str, request: Request, user: User = @router.post("/events/{event_id}/start-processing", status_code=202) -def start_processing(event_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def start_processing( + event_id: str, + request: Request, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id, lock=True) idem = reserve_idempotency(db, user, idempotency_key, f"start-processing:{event_id}") if idem and idem.response_body: return idem.response_body - queued = db.scalars(select(ProcessingJob).where(ProcessingJob.event_id == event.id, ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]))).all() + queued = db.scalars( + select(ProcessingJob).where( + ProcessingJob.event_id == event.id, + ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]), + ) + ).all() if not queued: raise HTTPException(status_code=409, detail="No media is queued for processing") event.status = "PROCESSING" for job in queued: - add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", job.id, {"job_id": job.id, "media_id": job.photo_id}, user.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.ml.process.requested", + "processing_job", + job.id, + {"job_id": job.id, "media_id": job.photo_id}, + user.organization_id, + request_id(request), + ) add_audit(db, user, "processing.started", f"{event.name}: {len(queued)} jobs") - result = ok({"event_id": event.id, "status": event.status, "jobs_queued": len(queued)}, request) + result = ok( + {"event_id": event.id, "status": event.status, "jobs_queued": len(queued)}, + request, + ) if idem: idem.response_status = 202 idem.response_body = result @@ -1296,65 +2600,333 @@ def start_processing(event_id: str, request: Request, idempotency_key: str | Non return result +@router.post("/events/{event_id}/cancel-processing", status_code=202) +def cancel_processing( + event_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): + event = tenant_event(db, user, event_id, lock=True) + jobs = db.scalars( + select(ProcessingJob) + .where( + ProcessingJob.event_id == event.id, + ProcessingJob.organization_id == user.organization_id, + ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED", "processing"]), + ) + .with_for_update() + ).all() + cancelled = 0 + cancellation_requested = 0 + for job in jobs: + if job.status == "processing": + job.status = "CANCEL_REQUESTED" + cancellation_requested += 1 + else: + job.status = "CANCELLED" + job.completed_at = utcnow() + cancelled += 1 + if job.photo_id: + photo = db.get(Photo, job.photo_id) + if photo: + photo.processing_status = "uploaded" + if event.status.upper() == "PROCESSING": + event.status = "READY_FOR_UPLOAD" + add_audit( + db, + user, + "processing.cancelled", + f"{event.id}: {cancelled} cancelled, {cancellation_requested} cancellation requested", + ) + db.commit() + return ok( + { + "event_id": event.id, + "cancelled": cancelled, + "cancellation_requested": cancellation_requested, + }, + request, + ) + + @router.get("/events/{event_id}/processing") -def processing_summary(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def processing_summary( + event_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id) - statuses = dict(db.execute(select(ProcessingJob.status, func.count(ProcessingJob.id)).where(ProcessingJob.event_id == event.id).group_by(ProcessingJob.status)).all()) + statuses = dict( + db.execute( + select(ProcessingJob.status, func.count(ProcessingJob.id)) + .where(ProcessingJob.event_id == event.id) + .group_by(ProcessingJob.status) + ).all() + ) photos_total = db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event.id)) or 0 - photos_processed = db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event.id, Photo.processing_status == "ready")) or 0 + photos_processed = ( + db.scalar(select(func.count(Photo.id)).where(Photo.event_id == event.id, Photo.processing_status == "ready")) + or 0 + ) faces = db.scalar(select(func.count(FaceDetection.id)).where(FaceDetection.event_id == event.id)) or 0 - decisions = dict(db.execute(select(FaceMatch.state, func.count(FaceMatch.id)).where(FaceMatch.event_id == event.id).group_by(FaceMatch.state)).all()) - return ok({"event_state": event.status, "photos_total": photos_total, "photos_processed": photos_processed, "photos_failed": statuses.get("failed", 0) + statuses.get("DEAD_LETTERED", 0), "faces_detected": faces, "matches_auto": decisions.get("high", 0) + decisions.get("approved", 0), "matches_review": decisions.get("review", 0), "matches_unknown": decisions.get("low", 0) + decisions.get("rejected", 0), "progress_percent": round(photos_processed * 100 / photos_total) if photos_total else 0}, request) + decisions = dict( + db.execute( + select(FaceMatch.state, func.count(FaceMatch.id)) + .where(FaceMatch.event_id == event.id) + .group_by(FaceMatch.state) + ).all() + ) + return ok( + { + "event_state": event.status, + "photos_total": photos_total, + "photos_processed": photos_processed, + "photos_failed": statuses.get("failed", 0) + statuses.get("DEAD_LETTERED", 0), + "faces_detected": faces, + "matches_auto": decisions.get("high", 0) + decisions.get("approved", 0), + "matches_review": decisions.get("review", 0), + "matches_unknown": decisions.get("low", 0) + decisions.get("rejected", 0), + "progress_percent": round(photos_processed * 100 / photos_total) if photos_total else 0, + }, + request, + ) @router.get("/events/{event_id}/processing/jobs") -def processing_jobs(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def processing_jobs( + event_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - rows = db.scalars(select(ProcessingJob).where(ProcessingJob.event_id == event_id, ProcessingJob.organization_id == user.organization_id).order_by(ProcessingJob.created_at.desc())).all() - return ok([{"id": row.id, "photo_id": row.photo_id, "job_type": row.job_type, "status": row.status, "attempt": row.attempt, "max_attempts": row.max_attempts, "progress_current": row.progress_current, "progress_total": row.progress_total, "error_code": "PROCESSING_FAILED" if row.error else None, "error_message": row.error, "queued_at": row.created_at.isoformat(), "started_at": row.started_at.isoformat() if row.started_at else None, "finished_at": row.completed_at.isoformat() if row.completed_at else None} for row in rows], request) + rows = db.scalars( + select(ProcessingJob) + .where( + ProcessingJob.event_id == event_id, + ProcessingJob.organization_id == user.organization_id, + ) + .order_by(ProcessingJob.created_at.desc()) + ).all() + return ok( + [ + { + "id": row.id, + "photo_id": row.photo_id, + "job_type": row.job_type, + "status": row.status, + "attempt": row.attempt, + "max_attempts": row.max_attempts, + "progress_current": row.progress_current, + "progress_total": row.progress_total, + "error_code": "PROCESSING_FAILED" if row.error else None, + "error_message": row.error, + "queued_at": row.created_at.isoformat(), + "started_at": row.started_at.isoformat() if row.started_at else None, + "finished_at": row.completed_at.isoformat() if row.completed_at else None, + } + for row in rows + ], + request, + ) @router.get("/events/{event_id}/processing/jobs/{job_id}") -def processing_job_detail(event_id: str, job_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def processing_job_detail( + event_id: str, + job_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - row = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.event_id == event_id, ProcessingJob.organization_id == user.organization_id)) + row = db.scalar( + select(ProcessingJob).where( + ProcessingJob.id == job_id, + ProcessingJob.event_id == event_id, + ProcessingJob.organization_id == user.organization_id, + ) + ) if not row: raise HTTPException(status_code=404, detail="Processing job was not found") - return ok({"id": row.id, "photo_id": row.photo_id, "job_type": row.job_type, "status": row.status, "attempt": row.attempt, "max_attempts": row.max_attempts, "progress_current": row.progress_current, "progress_total": row.progress_total, "error_message": row.error, "heartbeat_at": row.heartbeat_at.isoformat() if row.heartbeat_at else None}, request) + return ok( + { + "id": row.id, + "photo_id": row.photo_id, + "job_type": row.job_type, + "status": row.status, + "attempt": row.attempt, + "max_attempts": row.max_attempts, + "progress_current": row.progress_current, + "progress_total": row.progress_total, + "error_message": row.error, + "heartbeat_at": row.heartbeat_at.isoformat() if row.heartbeat_at else None, + }, + request, + ) @router.post("/events/{event_id}/processing/jobs/{job_id}/retry", status_code=202) -def retry_processing_job(event_id: str, job_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def retry_processing_job( + event_id: str, + job_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - row = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.event_id == event_id, ProcessingJob.organization_id == user.organization_id).with_for_update()) + row = db.scalar( + select(ProcessingJob) + .where( + ProcessingJob.id == job_id, + ProcessingJob.event_id == event_id, + ProcessingJob.organization_id == user.organization_id, + ) + .with_for_update() + ) if not row or row.status not in {"failed", "FAILED", "DEAD_LETTERED"}: raise HTTPException(status_code=409, detail="Processing job is not retryable") row.status = "queued" row.error = None row.next_attempt_at = utcnow() - add_outbox(db, "fdx.v2.ml.process.requested", "processing_job", row.id, {"job_id": row.id, "media_id": row.photo_id}, user.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.ml.process.requested", + "processing_job", + row.id, + {"job_id": row.id, "media_id": row.photo_id}, + user.organization_id, + request_id(request), + ) add_audit(db, user, "processing.retry", row.id) db.commit() return ok({"job_id": row.id, "status": "QUEUED"}, request) @router.get("/events/{event_id}/matches") -def event_matches(event_id: str, request: Request, decision: str | None = None, participant_id: str | None = None, minimum_score: float | None = None, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def event_matches( + event_id: str, + request: Request, + decision: str | None = None, + participant_id: str | None = None, + media_id: str | None = None, + review_required: bool | None = None, + minimum_score: float | None = None, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - filters = [FaceMatch.event_id == event_id, FaceMatch.organization_id == user.organization_id] + filters = [ + FaceMatch.event_id == event_id, + FaceMatch.organization_id == user.organization_id, + ] if decision: filters.append(FaceMatch.state == decision) if participant_id: filters.append(FaceMatch.participant_id == participant_id) + if media_id: + filters.append(FaceMatch.detection.has(FaceDetection.photo_id == media_id)) + if review_required is not None: + filters.append(FaceMatch.state == "review" if review_required else FaceMatch.state != "review") if minimum_score is not None: filters.append(FaceMatch.confidence >= minimum_score) rows = db.scalars(select(FaceMatch).where(*filters).order_by(FaceMatch.created_at.desc()).limit(500)).all() - return ok([{"id": row.id, "participant_id": row.participant_id, "media_id": row.detection.photo_id, "similarity_score": row.confidence, "second_best_score": row.second_best_score, "margin": row.margin, "decision": row.state, "decision_source": row.decision_source, "model_name": row.model_name, "model_version": row.model_version, "threshold_profile_version": row.threshold_profile_version} for row in rows], request) + return ok( + [ + { + "id": row.id, + "participant_id": row.participant_id, + "media_id": row.detection.photo_id, + "similarity_score": row.confidence, + "second_best_score": row.second_best_score, + "margin": row.margin, + "decision": row.state, + "decision_source": row.decision_source, + "model_name": row.model_name, + "model_version": row.model_version, + "threshold_profile_version": row.threshold_profile_version, + } + for row in rows + ], + request, + ) + + +@router.get("/events/{event_id}/matches/{match_id}") +def match_detail( + event_id: str, + match_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): + tenant_event(db, user, event_id) + row = db.scalar( + select(FaceMatch).where( + FaceMatch.id == match_id, + FaceMatch.event_id == event_id, + FaceMatch.organization_id == user.organization_id, + ) + ) + if not row: + raise HTTPException(status_code=404, detail="Match was not found") + photo = row.detection.photo + enrollment = row.participant.enrollment if row.participant else None + return ok( + { + "id": row.id, + "participant": { + "id": row.participant.id, + "name": row.participant.name, + "email": row.participant.email, + } + if row.participant + else None, + "media": { + "id": photo.id, + "filename": photo.filename, + "url": storage.presign_get(photo.storage_key) or f"/api/media/{photo.id}", + }, + "detection": { + "box": row.detection.box, + "landmarks": row.detection.landmarks, + "face_width": row.detection.face_width, + "face_height": row.detection.face_height, + "quality_class": row.detection.quality_class, + "detector_confidence": row.detection.detector_confidence, + }, + "enrollment_reference": { + "url": storage.presign_get(enrollment.storage_key), + "quality_score": enrollment.quality_score, + } + if enrollment + else None, + "similarity_score": row.confidence, + "second_best_score": row.second_best_score, + "margin": row.margin, + "decision": row.state, + "decision_source": row.decision_source, + "model_name": row.model_name, + "model_version": row.model_version, + "threshold_profile_version": row.threshold_profile_version, + }, + request, + ) def review_match(event_id: str, match_id: str, target: str, request: Request, user: User, db: Session): tenant_event(db, user, event_id) - row = db.scalar(select(FaceMatch).where(FaceMatch.id == match_id, FaceMatch.event_id == event_id, FaceMatch.organization_id == user.organization_id).with_for_update()) + row = db.scalar( + select(FaceMatch) + .where( + FaceMatch.id == match_id, + FaceMatch.event_id == event_id, + FaceMatch.organization_id == user.organization_id, + ) + .with_for_update() + ) if not row: raise HTTPException(status_code=404, detail="Match was not found") row.state = target @@ -1363,38 +2935,97 @@ def review_match(event_id: str, match_id: str, target: str, request: Request, us row.reviewed_at = utcnow() add_audit(db, user, f"match.{target}", row.id) db.commit() - return ok({"id": row.id, "decision": row.state, "decision_source": row.decision_source}, request) + return ok( + {"id": row.id, "decision": row.state, "decision_source": row.decision_source}, + request, + ) @router.post("/events/{event_id}/matches/{match_id}/confirm") -def confirm_match(event_id: str, match_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def confirm_match( + event_id: str, + match_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): return review_match(event_id, match_id, "approved", request, user, db) @router.post("/events/{event_id}/matches/{match_id}/reject") -def reject_match(event_id: str, match_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def reject_match( + event_id: str, + match_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): return review_match(event_id, match_id, "rejected", request, user, db) @router.post("/events/{event_id}/galleries/build", status_code=202) -def build_galleries(event_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def build_galleries( + event_id: str, + request: Request, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id, lock=True) idem = reserve_idempotency(db, user, idempotency_key, f"build-galleries:{event_id}") if idem and idem.response_body: return idem.response_body - participant_ids = db.scalars(select(FaceMatch.participant_id).where(FaceMatch.event_id == event.id, FaceMatch.organization_id == user.organization_id, FaceMatch.participant_id.is_not(None), FaceMatch.state.in_(["high", "approved"])).distinct()).all() + participant_ids = db.scalars( + select(FaceMatch.participant_id) + .where( + FaceMatch.event_id == event.id, + FaceMatch.organization_id == user.organization_id, + FaceMatch.participant_id.is_not(None), + FaceMatch.state.in_(["high", "approved"]), + ) + .distinct() + ).all() created = 0 for participant_id in participant_ids: - delivery = db.scalar(select(Delivery).where(Delivery.event_id == event.id, Delivery.participant_id == participant_id)) + delivery = db.scalar( + select(Delivery).where(Delivery.event_id == event.id, Delivery.participant_id == participant_id) + ) if not delivery: _, placeholder_hash = new_opaque_token() - delivery = Delivery(organization_id=user.organization_id, event_id=event.id, participant_id=participant_id, gallery_token_hash=placeholder_hash, status="ready", expires_at=datetime.combine(event.expires_at, datetime.min.time(), timezone.utc)) + delivery = Delivery( + organization_id=user.organization_id, + event_id=event.id, + participant_id=participant_id, + gallery_token_hash=placeholder_hash, + status="ready", + expires_at=datetime.combine(event.expires_at, datetime.min.time(), timezone.utc), + ) db.add(delivery) created += 1 - add_outbox(db, "fdx.v2.gallery.build.requested", "participant", participant_id, {"participant_id": participant_id, "event_id": event.id}, user.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.gallery.build.requested", + "participant", + participant_id, + {"participant_id": participant_id, "event_id": event.id}, + user.organization_id, + request_id(request), + ) event.status = "READY_TO_DELIVER" - add_audit(db, user, "gallery.build_requested", f"{event.name}: {len(participant_ids)} participants") - result = ok({"event_id": event.id, "galleries_ready": len(participant_ids), "galleries_created": created}, request) + add_audit( + db, + user, + "gallery.build_requested", + f"{event.name}: {len(participant_ids)} participants", + ) + result = ok( + { + "event_id": event.id, + "galleries_ready": len(participant_ids), + "galleries_created": created, + }, + request, + ) if idem: idem.response_status = 202 idem.response_body = result @@ -1403,20 +3034,76 @@ def build_galleries(event_id: str, request: Request, idempotency_key: str | None @router.get("/events/{event_id}/galleries") -def list_galleries(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def list_galleries( + event_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - rows = db.scalars(select(Delivery).where(Delivery.event_id == event_id, Delivery.organization_id == user.organization_id).order_by(Delivery.created_at.desc())).all() - return ok([{"id": row.id, "participant_id": row.participant_id, "participant_name": row.participant.name, "status": row.status.upper(), "access_expires_at": row.expires_at.isoformat(), "created_at": row.created_at.isoformat()} for row in rows], request) + rows = db.scalars( + select(Delivery) + .where( + Delivery.event_id == event_id, + Delivery.organization_id == user.organization_id, + ) + .order_by(Delivery.created_at.desc()) + ).all() + return ok( + [ + { + "id": row.id, + "participant_id": row.participant_id, + "participant_name": row.participant.name, + "status": row.status.upper(), + "access_expires_at": row.expires_at.isoformat(), + "created_at": row.created_at.isoformat(), + } + for row in rows + ], + request, + ) @router.get("/events/{event_id}/galleries/{gallery_id}") -def gallery_detail(event_id: str, gallery_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def gallery_detail( + event_id: str, + gallery_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - row = db.scalar(select(Delivery).where(Delivery.id == gallery_id, Delivery.event_id == event_id, Delivery.organization_id == user.organization_id)) + row = db.scalar( + select(Delivery).where( + Delivery.id == gallery_id, + Delivery.event_id == event_id, + Delivery.organization_id == user.organization_id, + ) + ) if not row: raise HTTPException(status_code=404, detail="Gallery was not found") - count = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.event_id == event_id, FaceMatch.participant_id == row.participant_id, FaceMatch.state.in_(["high", "approved"]))) or 0 - return ok({"id": row.id, "participant_id": row.participant_id, "participant_name": row.participant.name, "status": row.status.upper(), "photos": count, "access_expires_at": row.expires_at.isoformat()}, request) + count = ( + db.scalar( + select(func.count(FaceMatch.id)).where( + FaceMatch.event_id == event_id, + FaceMatch.participant_id == row.participant_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0 + ) + return ok( + { + "id": row.id, + "participant_id": row.participant_id, + "participant_name": row.participant.name, + "status": row.status.upper(), + "photos": count, + "access_expires_at": row.expires_at.isoformat(), + }, + request, + ) def deliver_gallery(db: Session, delivery: Delivery, user: User) -> str: @@ -1424,28 +3111,70 @@ def deliver_gallery(db: Session, delivery: Delivery, user: User) -> str: delivery.gallery_token_hash = token_hash delivery.status = "ready" gallery_url = f"{settings.frontend_url}/gallery/{raw_token}" - count = db.scalar(select(func.count(FaceMatch.id)).where(FaceMatch.event_id == delivery.event_id, FaceMatch.participant_id == delivery.participant_id, FaceMatch.state.in_(["high", "approved"]))) or 0 - mail = queue_email(db, delivery.organization_id, delivery.participant.email, f"Your photos from {delivery.event.name} are ready", f"

We found {count} photos containing you.

View My Photos

", delivery_id=delivery.id) + count = ( + db.scalar( + select(func.count(FaceMatch.id)).where( + FaceMatch.event_id == delivery.event_id, + FaceMatch.participant_id == delivery.participant_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) + or 0 + ) + mail = queue_email( + db, + delivery.organization_id, + delivery.participant.email, + f"Your photos from {delivery.event.name} are ready", + f"

We found {count} photos containing you.

View My Photos

", + delivery_id=delivery.id, + ) dispatch_email(db, mail) add_audit(db, user, "gallery.delivered", delivery.participant.email) return gallery_url @router.post("/events/{event_id}/deliveries/send", status_code=202) -def send_deliveries(event_id: str, request: Request, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def send_deliveries( + event_id: str, + request: Request, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): event = tenant_event(db, user, event_id, lock=True) idem = reserve_idempotency(db, user, idempotency_key, f"send-deliveries:{event_id}") if idem and idem.response_body: return idem.response_body - rows = db.scalars(select(Delivery).where(Delivery.event_id == event.id, Delivery.organization_id == user.organization_id)).all() + rows = db.scalars( + select(Delivery).where( + Delivery.event_id == event.id, + Delivery.organization_id == user.organization_id, + ) + ).all() development_urls = [] for row in rows: url = deliver_gallery(db, row, user) - add_outbox(db, "fdx.v2.email.send.requested", "delivery", row.id, {"delivery_id": row.id}, user.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.email.send.requested", + "delivery", + row.id, + {"delivery_id": row.id}, + user.organization_id, + request_id(request), + ) if settings.environment == "development": development_urls.append({"participant_id": row.participant_id, "url": url}) event.status = "DELIVERING" if rows else event.status - result = ok({"event_id": event.id, "deliveries_queued": len(rows), "development_gallery_urls": development_urls}, request) + result = ok( + { + "event_id": event.id, + "deliveries_queued": len(rows), + "development_gallery_urls": development_urls, + }, + request, + ) if idem: idem.response_status = 202 idem.response_body = result @@ -1454,16 +3183,53 @@ def send_deliveries(event_id: str, request: Request, idempotency_key: str | None @router.get("/events/{event_id}/deliveries") -def list_deliveries(event_id: str, request: Request, user: User = Depends(require_org_member), db: Session = Depends(get_db)): +def list_deliveries( + event_id: str, + request: Request, + user: User = Depends(require_org_member), + db: Session = Depends(get_db), +): tenant_event(db, user, event_id) - rows = db.scalars(select(Delivery).where(Delivery.event_id == event_id, Delivery.organization_id == user.organization_id).order_by(Delivery.created_at.desc())).all() - return ok([{"id": row.id, "participant_id": row.participant_id, "participant_name": row.participant.name, "status": row.status.upper(), "sent_at": row.sent_at.isoformat() if row.sent_at else None, "expires_at": row.expires_at.isoformat()} for row in rows], request) + rows = db.scalars( + select(Delivery) + .where( + Delivery.event_id == event_id, + Delivery.organization_id == user.organization_id, + ) + .order_by(Delivery.created_at.desc()) + ).all() + return ok( + [ + { + "id": row.id, + "participant_id": row.participant_id, + "participant_name": row.participant.name, + "status": row.status.upper(), + "sent_at": row.sent_at.isoformat() if row.sent_at else None, + "expires_at": row.expires_at.isoformat(), + } + for row in rows + ], + request, + ) @router.post("/events/{event_id}/participants/{participant_id}/resend-results") -def resend_results(event_id: str, participant_id: str, request: Request, user: User = Depends(require_org_admin), db: Session = Depends(get_db)): +def resend_results( + event_id: str, + participant_id: str, + request: Request, + user: User = Depends(require_org_admin), + db: Session = Depends(get_db), +): tenant_participant(db, user, event_id, participant_id) - delivery = db.scalar(select(Delivery).where(Delivery.event_id == event_id, Delivery.participant_id == participant_id, Delivery.organization_id == user.organization_id)) + delivery = db.scalar( + select(Delivery).where( + Delivery.event_id == event_id, + Delivery.participant_id == participant_id, + Delivery.organization_id == user.organization_id, + ) + ) if not delivery: raise HTTPException(status_code=409, detail="Participant gallery has not been built") url = deliver_gallery(db, delivery, user) @@ -1477,62 +3243,266 @@ def resend_results(event_id: str, participant_id: str, request: Request, user: U @router.get("/public/enrollment/{token}") def public_enrollment(token: str, request: Request, db: Session = Depends(get_db)): check_public_rate_limit(request, "enrollment-read", token, 30) - token_row = db.scalar(select(ParticipantEnrollmentToken).where(ParticipantEnrollmentToken.token_hash == hash_token(token)).with_for_update()) - participant = db.get(Participant, token_row.participant_id) if token_row else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + token_row = db.scalar( + select(ParticipantEnrollmentToken) + .where(ParticipantEnrollmentToken.token_hash == hash_token(token)) + .with_for_update() + ) + participant = ( + db.get(Participant, token_row.participant_id) + if token_row + else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + ) expires_at = token_row.expires_at if token_row else participant.enrollment_expires_at if participant else None - if not participant or not expires_at or expires_at <= utcnow() or (token_row and (token_row.revoked_at or token_row.consumed_at)): + if ( + not participant + or participant.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or not expires_at + or expires_at <= utcnow() + or (token_row and (token_row.revoked_at or token_row.consumed_at)) + ): raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") if token_row and not token_row.opened_at: token_row.opened_at = utcnow() participant.enrollment_status = "opened" db.commit() - return ok({"organization_name": participant.event.organization.name, "event_name": participant.event.name, "participant_name": participant.name, "status": participant.enrollment_status, "expires_at": expires_at.isoformat(), "purpose": "Find event photographs containing you", "retention_days": participant.event.retention_days, "consent_policy_version": settings.consent_policy_version}, request) + return ok( + { + "organization_name": participant.event.organization.name, + "event_name": participant.event.name, + "participant_name": participant.name, + "status": participant.enrollment_status, + "expires_at": expires_at.isoformat(), + "purpose": "Find event photographs containing you", + "retention_days": participant.event.retention_days, + "consent_policy_version": settings.consent_policy_version, + }, + request, + ) @router.post("/public/enrollment/{token}/consent", status_code=201) -def enrollment_consent(token: str, request: Request, accepted: bool = Form(...), db: Session = Depends(get_db)): +def enrollment_consent( + token: str, + request: Request, + accepted: bool = Form(...), + db: Session = Depends(get_db), +): check_public_rate_limit(request, "enrollment-consent", token, 10, 300) - token_row = db.scalar(select(ParticipantEnrollmentToken).where(ParticipantEnrollmentToken.token_hash == hash_token(token))) - participant = db.get(Participant, token_row.participant_id) if token_row else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + token_row = db.scalar( + select(ParticipantEnrollmentToken).where(ParticipantEnrollmentToken.token_hash == hash_token(token)) + ) + participant = ( + db.get(Participant, token_row.participant_id) + if token_row + else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + ) expires_at = token_row.expires_at if token_row else participant.enrollment_expires_at if participant else None - if not participant or not expires_at or expires_at <= utcnow() or (token_row and (token_row.revoked_at or token_row.consumed_at)): + if ( + not participant + or participant.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or not expires_at + or expires_at <= utcnow() + or (token_row and (token_row.revoked_at or token_row.consumed_at)) + ): raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") if not accepted: raise HTTPException(status_code=422, detail="Consent is required") - consent = Consent(organization_id=participant.organization_id, event_id=participant.event_id, participant_id=participant.id, consent_type="face_enrollment", policy_version=settings.consent_policy_version, accepted=True, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent")) + consent = Consent( + organization_id=participant.organization_id, + event_id=participant.event_id, + participant_id=participant.id, + consent_type="face_enrollment", + policy_version=settings.consent_policy_version, + accepted=True, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) db.add(consent) db.commit() - return ok({"consent_id": consent.id, "policy_version": consent.policy_version, "accepted_at": consent.accepted_at.isoformat()}, request) + return ok( + { + "consent_id": consent.id, + "policy_version": consent.policy_version, + "accepted_at": consent.accepted_at.isoformat(), + }, + request, + ) + + +@router.post("/public/enrollment/{token}/upload-url") +def enrollment_upload_url( + token: str, + payload: EnrollmentUploadInput, + request: Request, + db: Session = Depends(get_db), +): + check_public_rate_limit(request, "enrollment-upload-url", token, 10, 300) + token_row = db.scalar( + select(ParticipantEnrollmentToken) + .where(ParticipantEnrollmentToken.token_hash == hash_token(token)) + .with_for_update() + ) + participant = db.get(Participant, token_row.participant_id) if token_row else None + if ( + not token_row + or not participant + or participant.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or token_row.expires_at <= utcnow() + or token_row.revoked_at + or token_row.consumed_at + ): + raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") + if payload.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=422, detail="A JPEG, PNG, or WEBP selfie is required") + if payload.size_bytes > settings.max_enrollment_bytes: + raise HTTPException(status_code=413, detail="Enrollment image exceeds the configured maximum") + expected_extensions = { + "image/jpeg": {".jpg", ".jpeg"}, + "image/png": {".png"}, + "image/webp": {".webp"}, + } + suffix = "." + payload.filename.lower().rsplit(".", 1)[-1] if "." in payload.filename else "" + if suffix not in expected_extensions[payload.content_type]: + raise HTTPException(status_code=422, detail="Filename extension does not match the image type") + if token_row.pending_storage_key: + storage.delete(token_row.pending_storage_key) + extension = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[payload.content_type] + key = f"organizations/{participant.organization_id}/events/{participant.event_id}/enrollment-pending/{token_row.id}/{uuid.uuid4()}{extension}" + token_row.pending_storage_key = key + token_row.pending_content_type = payload.content_type + token_row.pending_size_bytes = payload.size_bytes + token_row.pending_sha256 = payload.sha256.lower() + upload_url = storage.presign_put(key, payload.content_type) or f"/api/v2/public/enrollment/{token}/upload" + db.commit() + return ok( + { + "upload_url": upload_url, + "method": "PUT", + "headers": {"Content-Type": payload.content_type}, + "expires_in": 900, + }, + request, + ) + + +@router.put("/public/enrollment/{token}/upload", status_code=204) +async def enrollment_local_upload(token: str, request: Request, db: Session = Depends(get_db)): + check_public_rate_limit(request, "enrollment-upload", token, 10, 300) + if storage.s3: + raise HTTPException(status_code=404, detail="Direct upload endpoint is unavailable") + token_row = db.scalar( + select(ParticipantEnrollmentToken) + .where(ParticipantEnrollmentToken.token_hash == hash_token(token)) + .with_for_update() + ) + participant = db.get(Participant, token_row.participant_id) if token_row else None + if ( + not token_row + or not participant + or not token_row.pending_storage_key + or token_row.expires_at <= utcnow() + or token_row.revoked_at + or token_row.consumed_at + ): + raise HTTPException(status_code=404, detail="Enrollment upload is invalid or expired") + content = await request.body() + if len(content) != token_row.pending_size_bytes or hashlib.sha256(content).hexdigest() != token_row.pending_sha256: + raise HTTPException(status_code=422, detail="Enrollment upload does not match its manifest") + storage.put(token_row.pending_storage_key, content, token_row.pending_content_type) + db.commit() + return Response(status_code=204) @router.post("/public/enrollment/{token}/complete") -def complete_enrollment(token: str, request: Request, selfie: UploadFile = File(...), db: Session = Depends(get_db)): +def complete_enrollment( + token: str, + request: Request, + selfie: UploadFile | None = File(default=None), + db: Session = Depends(get_db), +): check_public_rate_limit(request, "enrollment-complete", token, 10, 300) - token_row = db.scalar(select(ParticipantEnrollmentToken).where(ParticipantEnrollmentToken.token_hash == hash_token(token)).with_for_update()) - participant = db.get(Participant, token_row.participant_id) if token_row else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + token_row = db.scalar( + select(ParticipantEnrollmentToken) + .where(ParticipantEnrollmentToken.token_hash == hash_token(token)) + .with_for_update() + ) + participant = ( + db.get(Participant, token_row.participant_id) + if token_row + else db.scalar(select(Participant).where(Participant.enrollment_token_hash == hash_token(token))) + ) expires_at = token_row.expires_at if token_row else participant.enrollment_expires_at if participant else None - if not participant or not expires_at or expires_at <= utcnow() or (token_row and (token_row.revoked_at or token_row.consumed_at)): + if ( + not participant + or participant.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or not expires_at + or expires_at <= utcnow() + or (token_row and (token_row.revoked_at or token_row.consumed_at)) + ): raise HTTPException(status_code=404, detail="Enrollment link is invalid or expired") - consent = db.scalar(select(Consent).where(Consent.participant_id == participant.id, Consent.accepted.is_(True)).order_by(Consent.accepted_at.desc())) + consent = db.scalar( + select(Consent) + .where(Consent.participant_id == participant.id, Consent.accepted.is_(True)) + .order_by(Consent.accepted_at.desc()) + ) if not consent: raise HTTPException(status_code=422, detail="Consent must be recorded before enrollment") - content = selfie.file.read() - content_type = selfie.content_type or "application/octet-stream" + if selfie: + content = selfie.file.read() + content_type = selfie.content_type or "application/octet-stream" + filename = selfie.filename or "selfie.jpg" + elif token_row and token_row.pending_storage_key: + try: + info = storage.stat(token_row.pending_storage_key) + content, stored_type = storage.read(token_row.pending_storage_key) + except FileNotFoundError as exc: + raise HTTPException(status_code=409, detail="Enrollment upload has not completed") from exc + if ( + info["size"] != token_row.pending_size_bytes + or hashlib.sha256(content).hexdigest() != token_row.pending_sha256 + ): + raise HTTPException(status_code=422, detail="Enrollment upload does not match its manifest") + content_type = token_row.pending_content_type or stored_type + filename = token_row.pending_storage_key.rsplit("/", 1)[-1] + else: + raise HTTPException(status_code=422, detail="An enrollment upload is required") if not content or content_type not in ALLOWED_IMAGE_TYPES: raise HTTPException(status_code=422, detail="A JPEG, PNG, or WEBP selfie is required") + if len(content) > settings.max_enrollment_bytes: + raise HTTPException(status_code=413, detail="Enrollment image exceeds the configured maximum") try: with Image.open(io.BytesIO(content)) as image: + if image.width * image.height > settings.max_image_pixels: + raise ValueError("Enrollment image exceeds the pixel limit") image.verify() - result = ml_embedding(content, selfie.filename or "selfie.jpg", content_type) + result = ml_embedding(content, filename, content_type) + face_width = int(result["box"].get("x_max", 0) - result["box"].get("x_min", 0)) + face_height = int(result["box"].get("y_max", 0) - result["box"].get("y_min", 0)) + if ( + min(face_width, face_height) < settings.minimum_face_size + or result["box"]["probability"] < settings.minimum_detector_confidence + ): + raise ValueError("Enrollment face is too small or unclear") except Exception as exc: raise HTTPException(status_code=422, detail="A clear, usable face could not be enrolled") from exc previous_size = participant.enrollment.size_bytes if participant.enrollment else 0 additional = len(content) - previous_size - if participant.event.organization.storage_used_bytes + additional > participant.event.organization.storage_limit_bytes: + if ( + participant.event.organization.storage_used_bytes + additional + > participant.event.organization.storage_limit_bytes + ): raise HTTPException(status_code=413, detail="Organization storage quota would be exceeded") - key = f"organizations/{participant.organization_id}/events/{participant.event_id}/enrollment/{participant.id}/{uuid.uuid4()}.jpg" + extension = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type] + key = f"organizations/{participant.organization_id}/events/{participant.event_id}/enrollment/{participant.id}/{uuid.uuid4()}{extension}" storage.put(key, content, content_type) - enrollment = participant.enrollment or FaceEnrollment(participant_id=participant.id, storage_key=key, embedding=result["embedding"], detector_confidence=result["box"]["probability"]) + previous_key = participant.enrollment.storage_key if participant.enrollment else None + enrollment = participant.enrollment or FaceEnrollment( + participant_id=participant.id, + storage_key=key, + embedding=result["embedding"], + detector_confidence=result["box"]["probability"], + ) enrollment.organization_id = participant.organization_id enrollment.event_id = participant.event_id enrollment.storage_key = key @@ -1541,6 +3511,7 @@ def complete_enrollment(token: str, request: Request, selfie: UploadFile = File( enrollment.embedding_vector = result["embedding"] enrollment.embedding_dimension = len(result["embedding"]) enrollment.detector_confidence = result["box"]["probability"] + enrollment.quality_score = min(1.0, min(face_width, face_height) / settings.low_resolution_face_size) enrollment.model_name = "adaface-ir101-ms1mv2" enrollment.model_version = settings.embedder_model_version enrollment.status = "valid" @@ -1549,27 +3520,76 @@ def complete_enrollment(token: str, request: Request, selfie: UploadFile = File( participant.enrollment_status = "verified" participant.consented_at = consent.accepted_at participant.event.organization.storage_used_bytes += additional - db.add(StorageUsageLedger(organization_id=participant.organization_id, event_id=participant.event_id, operation="ADD", bytes=additional)) + db.add( + StorageUsageLedger( + organization_id=participant.organization_id, + event_id=participant.event_id, + operation="ADD", + bytes=additional, + ) + ) if token_row: token_row.consumed_at = utcnow() + pending_key = token_row.pending_storage_key + token_row.pending_storage_key = None + token_row.pending_content_type = None + token_row.pending_size_bytes = None + token_row.pending_sha256 = None add_audit(db, None, "enrollment.completed", participant.email, participant.organization_id) db.commit() - return ok({"status": "ENROLLED", "embedding_dimension": enrollment.embedding_dimension, "model_name": enrollment.model_name, "model_version": enrollment.model_version}, request) + if previous_key and previous_key != key: + storage.delete(previous_key) + if token_row and pending_key and pending_key != key: + storage.delete(pending_key) + return ok( + { + "status": "ENROLLED", + "embedding_dimension": enrollment.embedding_dimension, + "model_name": enrollment.model_name, + "model_version": enrollment.model_version, + }, + request, + ) @router.get("/public/gallery/{token}") def public_gallery(token: str, request: Request, db: Session = Depends(get_db)): check_public_rate_limit(request, "gallery-read", token, 60) delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) - if not delivery or delivery.expires_at <= utcnow(): + if ( + not delivery + or delivery.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or delivery.expires_at <= utcnow() + ): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - rows = db.scalars(select(FaceMatch).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceMatch.state.in_(["high", "approved"]))).all() + rows = db.scalars( + select(FaceMatch).where( + FaceMatch.participant_id == delivery.participant_id, + FaceMatch.event_id == delivery.event_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ).all() photos = {row.detection.photo.id: row.detection.photo for row in rows} data = [] for photo in photos.values(): signed = storage.presign_get(photo.thumbnail_storage_key or photo.storage_key) - data.append({"id": photo.id, "filename": photo.filename, "thumbnail_url": signed or f"/api/public/gallery/{token}/photos/{photo.id}/thumbnail", "download_endpoint": f"/api/v2/public/gallery/{token}/download-url"}) - return ok({"event_name": delivery.event.name, "organization_name": delivery.event.organization.name, "expires_at": delivery.expires_at.isoformat(), "photos": data}, request) + data.append( + { + "id": photo.id, + "filename": photo.filename, + "thumbnail_url": signed or f"/api/public/gallery/{token}/photos/{photo.id}/thumbnail", + "download_endpoint": f"/api/v2/public/gallery/{token}/download-url", + } + ) + return ok( + { + "event_name": delivery.event.name, + "organization_name": delivery.event.organization.name, + "expires_at": delivery.expires_at.isoformat(), + "photos": data, + }, + request, + ) class DownloadInput(BaseModel): @@ -1580,28 +3600,53 @@ class DownloadInput(BaseModel): def gallery_download_url(token: str, payload: DownloadInput, request: Request, db: Session = Depends(get_db)): check_public_rate_limit(request, "gallery-download", token, 60) delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) - if not delivery or delivery.expires_at <= utcnow(): + if ( + not delivery + or delivery.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or delivery.expires_at <= utcnow() + ): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - match = db.scalar(select(FaceMatch).join(FaceDetection).where(FaceMatch.participant_id == delivery.participant_id, FaceMatch.event_id == delivery.event_id, FaceDetection.photo_id == payload.media_id, FaceMatch.state.in_(["high", "approved"]))) + match = db.scalar( + select(FaceMatch) + .join(FaceDetection) + .where( + FaceMatch.participant_id == delivery.participant_id, + FaceMatch.event_id == delivery.event_id, + FaceDetection.photo_id == payload.media_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ) if not match: raise HTTPException(status_code=404, detail="Photo was not found") photo = match.detection.photo - url = storage.presign_get(photo.storage_key, filename=photo.filename) or f"/api/public/gallery/{token}/photos/{photo.id}" + url = ( + storage.presign_get(photo.storage_key, filename=photo.filename) + or f"/api/public/gallery/{token}/photos/{photo.id}" + ) return ok({"url": url, "expires_in": 600}, request) +@router.post("/public/gallery/{token}/download-all", status_code=202) @router.post("/public/gallery/{token}/exports", status_code=202) def create_gallery_export(token: str, request: Request, db: Session = Depends(get_db)): check_public_rate_limit(request, "gallery-export", token, 5, 300) delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) - if not delivery or delivery.expires_at <= utcnow(): + if ( + not delivery + or delivery.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or delivery.expires_at <= utcnow() + ): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - existing = db.scalar(select(GalleryExport).where( - GalleryExport.participant_id == delivery.participant_id, - GalleryExport.event_id == delivery.event_id, - GalleryExport.status.in_(["QUEUED", "PROCESSING", "READY"]), - GalleryExport.expires_at > utcnow(), - ).order_by(GalleryExport.created_at.desc())) + existing = db.scalar( + select(GalleryExport) + .where( + GalleryExport.participant_id == delivery.participant_id, + GalleryExport.event_id == delivery.event_id, + GalleryExport.status.in_(["QUEUED", "PROCESSING", "READY"]), + GalleryExport.expires_at > utcnow(), + ) + .order_by(GalleryExport.created_at.desc()) + ) if existing: return ok({"export_id": existing.id, "status": existing.status}, request) job = ProcessingJob( @@ -1623,7 +3668,15 @@ def create_gallery_export(token: str, request: Request, db: Session = Depends(ge ) db.add(export) db.flush() - add_outbox(db, "fdx.v2.gallery.export.requested", "gallery_export", export.id, {"job_id": job.id, "export_id": export.id}, delivery.organization_id, request_id(request)) + add_outbox( + db, + "fdx.v2.gallery.export.requested", + "gallery_export", + export.id, + {"job_id": job.id, "export_id": export.id}, + delivery.organization_id, + request_id(request), + ) db.commit() return ok({"export_id": export.id, "status": export.status}, request) @@ -1632,19 +3685,39 @@ def create_gallery_export(token: str, request: Request, db: Session = Depends(ge def gallery_export_status(token: str, export_id: str, request: Request, db: Session = Depends(get_db)): check_public_rate_limit(request, "gallery-export-status", token, 60) delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) - if not delivery or delivery.expires_at <= utcnow(): + if ( + not delivery + or delivery.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or delivery.expires_at <= utcnow() + ): raise HTTPException(status_code=404, detail="Gallery link is invalid or expired") - item = db.scalar(select(GalleryExport).where( - GalleryExport.id == export_id, - GalleryExport.event_id == delivery.event_id, - GalleryExport.participant_id == delivery.participant_id, - )) + item = db.scalar( + select(GalleryExport).where( + GalleryExport.id == export_id, + GalleryExport.event_id == delivery.event_id, + GalleryExport.participant_id == delivery.participant_id, + ) + ) if not item or item.expires_at <= utcnow(): raise HTTPException(status_code=404, detail="Gallery export was not found or has expired") - url = storage.presign_get(item.storage_key, filename=f"{delivery.event.name}-photos.zip") if item.status == "READY" and item.storage_key else None + url = ( + storage.presign_get(item.storage_key, filename=f"{delivery.event.name}-photos.zip") + if item.status == "READY" and item.storage_key + else None + ) if item.status == "READY" and not url: url = f"/api/v2/public/gallery/{token}/exports/{item.id}/download" - return ok({"export_id": item.id, "status": item.status, "size_bytes": item.size_bytes, "download_url": url, "expires_at": item.expires_at.isoformat(), "error": item.error}, request) + return ok( + { + "export_id": item.id, + "status": item.status, + "size_bytes": item.size_bytes, + "download_url": url, + "expires_at": item.expires_at.isoformat(), + "error": item.error, + }, + request, + ) @router.get("/public/gallery/{token}/exports/{export_id}/download") @@ -1652,11 +3725,33 @@ def download_gallery_export(token: str, export_id: str, request: Request, db: Se check_public_rate_limit(request, "gallery-export-download", token, 20, 300) delivery = db.scalar(select(Delivery).where(Delivery.gallery_token_hash == hash_token(token))) item = db.scalar(select(GalleryExport).where(GalleryExport.id == export_id)) - if not delivery or delivery.expires_at <= utcnow() or not item or item.participant_id != delivery.participant_id or item.event_id != delivery.event_id or item.status != "READY" or item.expires_at <= utcnow() or not item.storage_key: + if ( + not delivery + or delivery.event.status.upper() in {"DELETION_PENDING", "DELETED", "EXPIRED"} + or delivery.expires_at <= utcnow() + or not item + or item.participant_id != delivery.participant_id + or item.event_id != delivery.event_id + or item.status != "READY" + or item.expires_at <= utcnow() + or not item.storage_key + ): raise HTTPException(status_code=404, detail="Gallery export was not found or has expired") content, _ = storage.read(item.storage_key) - filename = "".join(character if character.isalnum() or character in "-_" else "-" for character in delivery.event.name).strip("-") or "fdx-gallery" - return Response(content=content, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}.zip"', "Cache-Control": "private, no-store"}) + filename = ( + "".join( + character if character.isalnum() or character in "-_" else "-" for character in delivery.event.name + ).strip("-") + or "fdx-gallery" + ) + return Response( + content=content, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{filename}.zip"', + "Cache-Control": "private, no-store", + }, + ) def verify_webhook_signature(body: bytes, signature: str | None) -> None: @@ -1680,11 +3775,22 @@ async def process_email_webhook(provider: str, request: Request, signature: str provider_event_id = str(payload.get("id") or payload.get("event_id") or "") if not provider_event_id: raise HTTPException(status_code=422, detail="Webhook event ID is required") - if db.scalar(select(WebhookEvent.id).where(WebhookEvent.provider == provider, WebhookEvent.provider_event_id == provider_event_id)): + if db.scalar( + select(WebhookEvent.id).where( + WebhookEvent.provider == provider, + WebhookEvent.provider_event_id == provider_event_id, + ) + ): return ok({"status": "DUPLICATE"}, request) db.add(WebhookEvent(provider=provider, provider_event_id=provider_event_id, payload=payload)) - provider_message_id = payload.get("data", {}).get("email_id") or payload.get("mail", {}).get("messageId") or payload.get("message_id") - item = db.scalar(select(EmailOutbox).where(EmailOutbox.provider_id == provider_message_id)) if provider_message_id else None + provider_message_id = ( + payload.get("data", {}).get("email_id") or payload.get("mail", {}).get("messageId") or payload.get("message_id") + ) + item = ( + db.scalar(select(EmailOutbox).where(EmailOutbox.provider_id == provider_message_id)) + if provider_message_id + else None + ) if item: event_type = str(payload.get("type") or payload.get("eventType") or "").lower() if any(value in event_type for value in ("delivered", "delivery")): @@ -1703,10 +3809,18 @@ async def process_email_webhook(provider: str, request: Request, signature: str @router.post("/webhooks/email/resend") -async def resend_webhook(request: Request, x_fdx_webhook_signature: str | None = Header(default=None, alias="X-FDX-Webhook-Signature"), db: Session = Depends(get_db)): +async def resend_webhook( + request: Request, + x_fdx_webhook_signature: str | None = Header(default=None, alias="X-FDX-Webhook-Signature"), + db: Session = Depends(get_db), +): return await process_email_webhook("resend", request, x_fdx_webhook_signature, db) @router.post("/webhooks/email/ses") -async def ses_webhook(request: Request, x_fdx_webhook_signature: str | None = Header(default=None, alias="X-FDX-Webhook-Signature"), db: Session = Depends(get_db)): +async def ses_webhook( + request: Request, + x_fdx_webhook_signature: str | None = Header(default=None, alias="X-FDX-Webhook-Signature"), + db: Session = Depends(get_db), +): return await process_email_webhook("ses", request, x_fdx_webhook_signature, db) diff --git a/backend/app/worker.py b/backend/app/worker.py index 61c1e52..0d5f6f6 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -29,6 +29,7 @@ Organization, OutboxEvent, Participant, + ParticipantEnrollmentToken, Photo, ProcessingJob, RefreshSession, @@ -43,9 +44,21 @@ WORKER_NAME = f"ml-{socket.gethostname()}-{os.getpid()}" +class PermanentJobError(Exception): + """An invalid input that must not consume the transient retry budget.""" + + def process_gallery_export(job_id: str) -> None: with SessionLocal() as db: - job = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.job_type == "GALLERY_EXPORT", ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"])).with_for_update(skip_locked=True)) + job = db.scalar( + select(ProcessingJob) + .where( + ProcessingJob.id == job_id, + ProcessingJob.job_type == "GALLERY_EXPORT", + ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]), + ) + .with_for_update(skip_locked=True) + ) if not job or (job.next_attempt_at and job.next_attempt_at > utcnow()): return item = db.scalar(select(GalleryExport).where(GalleryExport.processing_job_id == job.id).with_for_update()) @@ -59,11 +72,13 @@ def process_gallery_export(job_id: str) -> None: item.status = "PROCESSING" db.commit() try: - matches = db.scalars(select(FaceMatch).where( - FaceMatch.event_id == item.event_id, - FaceMatch.participant_id == item.participant_id, - FaceMatch.state.in_(["high", "approved"]), - )).all() + matches = db.scalars( + select(FaceMatch).where( + FaceMatch.event_id == item.event_id, + FaceMatch.participant_id == item.participant_id, + FaceMatch.state.in_(["high", "approved"]), + ) + ).all() photos = {match.detection.photo.id: match.detection.photo for match in matches} archive = io.BytesIO() with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as bundle: @@ -72,6 +87,13 @@ def process_gallery_export(job_id: str) -> None: filename = os.path.basename(photo.filename).replace("..", "_") or f"photo-{index}.jpg" bundle.writestr(f"{index:04d}-{photo.id[:8]}-{filename}", content) content = archive.getvalue() + db.refresh(job) + if job.status == "CANCEL_REQUESTED": + job.status = "CANCELLED" + job.completed_at = utcnow() + item.status = "CANCELLED" + db.commit() + return organization = db.get(Organization, item.organization_id) if organization.storage_used_bytes + len(content) > organization.storage_limit_bytes: raise ValueError("Organization storage quota would be exceeded by the gallery export") @@ -83,7 +105,14 @@ def process_gallery_export(job_id: str) -> None: item.completed_at = utcnow() item.error = None organization.storage_used_bytes += len(content) - db.add(StorageUsageLedger(organization_id=item.organization_id, event_id=item.event_id, operation="ADD", bytes=len(content))) + db.add( + StorageUsageLedger( + organization_id=item.organization_id, + event_id=item.event_id, + operation="ADD", + bytes=len(content), + ) + ) job.status = "completed" job.progress = 100 job.progress_current = 100 @@ -99,12 +128,23 @@ def process_gallery_export(job_id: str) -> None: if job: job.status = "RETRY_SCHEDULED" if retryable else "DEAD_LETTERED" job.error = str(exc) - job.next_attempt_at = utcnow() + timedelta(seconds=retry_delays[min(job.attempt, len(retry_delays) - 1)]) if retryable else None + job.next_attempt_at = ( + utcnow() + timedelta(seconds=retry_delays[min(job.attempt, len(retry_delays) - 1)]) + if retryable + else None + ) job.completed_at = None if retryable else utcnow() if item: item.status = "QUEUED" if retryable else "FAILED" item.error = str(exc) - audit(db, None, "Gallery export failed", f"{job_id}: {exc}", "danger", item.organization_id if item else None) + audit( + db, + None, + "Gallery export failed", + f"{job_id}: {exc}", + "danger", + item.organization_id if item else None, + ) db.commit() @@ -122,7 +162,14 @@ def process_job(job_id: str) -> None: process_gallery_export(job_id) return with SessionLocal() as db: - job = db.scalar(select(ProcessingJob).where(ProcessingJob.id == job_id, ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"])).with_for_update(skip_locked=True)) + job = db.scalar( + select(ProcessingJob) + .where( + ProcessingJob.id == job_id, + ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]), + ) + .with_for_update(skip_locked=True) + ) if not job: return if job.next_attempt_at and job.next_attempt_at > utcnow(): @@ -140,12 +187,14 @@ def process_job(job_id: str) -> None: try: content, content_type = storage.read(photo.storage_key) if hashlib.sha256(content).hexdigest() != photo.sha256: - raise ValueError("Media checksum does not match the verified upload manifest") + raise PermanentJobError("Media checksum does not match the verified upload manifest") with Image.open(io.BytesIO(content)) as source: detected_type = Image.MIME.get(source.format) + if source.width * source.height > settings.max_image_pixels: + raise PermanentJobError("Media exceeds the configured pixel limit") source.verify() if detected_type not in {"image/jpeg", "image/png", "image/webp"} or detected_type != photo.content_type: - raise ValueError("Media magic bytes do not match the declared content type") + raise PermanentJobError("Media magic bytes do not match the declared content type") if not photo.thumbnail_storage_key: with Image.open(io.BytesIO(content)) as source: thumbnail = ImageOps.exif_transpose(source).convert("RGB") @@ -153,17 +202,46 @@ def process_job(job_id: str) -> None: output = io.BytesIO() thumbnail.save(output, format="WEBP", quality=82, method=6) thumbnail_content = output.getvalue() - thumbnail_key = f"organizations/{job.organization_id}/events/{job.event_id}/media/thumbnails/{photo.id}.webp" + thumbnail_key = ( + f"organizations/{job.organization_id}/events/{job.event_id}/media/thumbnails/{photo.id}.webp" + ) storage.put(thumbnail_key, thumbnail_content, "image/webp") photo.thumbnail_storage_key = thumbnail_key photo.thumbnail_size_bytes = len(thumbnail_content) organization = db.get(Organization, job.organization_id) organization.storage_used_bytes += len(thumbnail_content) - db.add(StorageUsageLedger(organization_id=job.organization_id, event_id=job.event_id, photo_id=photo.id, operation="ADD", bytes=len(thumbnail_content))) + db.add( + StorageUsageLedger( + organization_id=job.organization_id, + event_id=job.event_id, + photo_id=photo.id, + operation="ADD", + bytes=len(thumbnail_content), + ) + ) results = ml_faces(content, photo.filename, content_type) - enrollments = db.scalars(select(FaceEnrollment).join(Participant).where(Participant.event_id == job.event_id)).all() + db.refresh(job) + if job.status == "CANCEL_REQUESTED": + job.status = "CANCELLED" + job.completed_at = utcnow() + photo.processing_status = "uploaded" + db.commit() + return + enrollments = db.scalars( + select(FaceEnrollment).join(Participant).where(Participant.event_id == job.event_id) + ).all() for face_index, result in enumerate(results): box = result["box"] + face_width = max(0, int(box.get("x_max", 0) - box.get("x_min", 0))) + face_height = max(0, int(box.get("y_max", 0) - box.get("y_min", 0))) + minimum_dimension = min(face_width, face_height) + probability = float(box["probability"]) + if minimum_dimension < settings.minimum_face_size or probability < settings.minimum_detector_confidence: + quality_class = "REJECTED" + elif minimum_dimension < settings.low_resolution_face_size: + quality_class = "LOW_RESOLUTION" + else: + quality_class = "GOOD" detection = FaceDetection( organization_id=job.organization_id, event_id=job.event_id, @@ -171,67 +249,128 @@ def process_job(job_id: str) -> None: face_index=face_index, box=box, landmarks=result.get("landmarks"), - face_width=max(0, int(box.get("x_max", 0) - box.get("x_min", 0))), - face_height=max(0, int(box.get("y_max", 0) - box.get("y_min", 0))), + face_width=face_width, + face_height=face_height, embedding=result["embedding"], embedding_vector=result["embedding"], - detector_confidence=box["probability"], + detector_confidence=probability, + quality_class=quality_class, model_name="retinaface-r50", model_version=settings.detector_model_version, ) db.add(detection) db.flush() - ranked = sorted(((cosine(result["embedding"], enrollment.embedding), enrollment.participant_id) for enrollment in enrollments), reverse=True) + ranked = sorted( + ( + ( + cosine(result["embedding"], enrollment.embedding), + enrollment.participant_id, + ) + for enrollment in enrollments + ), + reverse=True, + ) best_score, participant_id = ranked[0] if ranked else (0.0, None) runner_up = ranked[1][0] if len(ranked) > 1 else -1.0 # A conservative margin prevents lookalikes from being assigned automatically. margin = best_score - runner_up - if best_score >= settings.match_auto_threshold and margin >= settings.match_runner_up_margin: + threshold_boost = settings.low_resolution_threshold_boost if quality_class == "LOW_RESOLUTION" else 0.0 + if quality_class == "REJECTED": + state = "low" + participant_id = None + elif ( + best_score >= settings.match_auto_threshold + threshold_boost + and margin >= settings.match_runner_up_margin + ): state = "high" - elif best_score >= settings.match_review_threshold: + elif best_score >= settings.match_review_threshold + threshold_boost: state = "review" else: state = "low" participant_id = None - db.add(FaceMatch( - organization_id=job.organization_id, - event_id=job.event_id, - detection_id=detection.id, - participant_id=participant_id, - confidence=max(0.0, best_score), - second_best_score=runner_up if runner_up >= 0 else None, - margin=margin if ranked else None, - state=state, - decision_source="AUTO", - model_name="adaface-ir101-ms1mv2", - model_version=settings.embedder_model_version, - threshold_profile_version=settings.threshold_profile_version, - )) + db.add( + FaceMatch( + organization_id=job.organization_id, + event_id=job.event_id, + detection_id=detection.id, + participant_id=participant_id, + confidence=max(0.0, best_score), + second_best_score=runner_up if runner_up >= 0 else None, + margin=margin if ranked else None, + state=state, + decision_source="AUTO", + model_name="adaface-ir101-ms1mv2", + model_version=settings.embedder_model_version, + threshold_profile_version=settings.threshold_profile_version, + ) + ) job.status = "completed" job.progress = 100 job.progress_current = 100 job.completed_at = utcnow() job.heartbeat_at = utcnow() photo.processing_status = "ready" - db.add(OutboxEvent( - aggregate_type="processing_job", - aggregate_id=job.id, - organization_id=job.organization_id, - event_type="fdx.v2.ml.process.completed", - event_version=1, - correlation_id=job.correlation_id or job.id, - payload={"job_id": job.id, "media_id": photo.id, "faces_detected": len(results)}, - )) + db.add( + OutboxEvent( + aggregate_type="processing_job", + aggregate_id=job.id, + organization_id=job.organization_id, + event_type="fdx.v2.ml.process.completed", + event_version=1, + correlation_id=job.correlation_id or job.id, + payload={ + "job_id": job.id, + "media_id": photo.id, + "faces_detected": len(results), + }, + ) + ) db.flush() - remaining = db.scalar(select(func.count(ProcessingJob.id)).where(ProcessingJob.event_id == job.event_id, ProcessingJob.status.in_(["queued", "processing"]))) or 0 + remaining = ( + db.scalar( + select(func.count(ProcessingJob.id)).where( + ProcessingJob.event_id == job.event_id, + ProcessingJob.status.in_(["queued", "processing", "RETRY_SCHEDULED", "CANCEL_REQUESTED"]), + ) + ) + or 0 + ) if remaining == 0: event = db.get(Event, job.event_id) event.status = "ready" - participant_ids = db.scalars(select(FaceMatch.participant_id).where(FaceMatch.event_id == event.id, FaceMatch.participant_id.is_not(None), FaceMatch.state == "high").distinct()).all() + participant_ids = db.scalars( + select(FaceMatch.participant_id) + .where( + FaceMatch.event_id == event.id, + FaceMatch.participant_id.is_not(None), + FaceMatch.state == "high", + ) + .distinct() + ).all() for participant_id in participant_ids: - if not db.scalar(select(Delivery).where(Delivery.event_id == event.id, Delivery.participant_id == participant_id)): - db.add(Delivery(organization_id=job.organization_id, event_id=event.id, participant_id=participant_id, gallery_token_hash=os.urandom(32).hex(), status="ready", expires_at=datetime.combine(event.expires_at, datetime.min.time(), timezone.utc))) - audit(db, None, "Event processing completed", event.name, organization_id=job.organization_id) + if not db.scalar( + select(Delivery).where( + Delivery.event_id == event.id, + Delivery.participant_id == participant_id, + ) + ): + db.add( + Delivery( + organization_id=job.organization_id, + event_id=event.id, + participant_id=participant_id, + gallery_token_hash=os.urandom(32).hex(), + status="ready", + expires_at=datetime.combine(event.expires_at, datetime.min.time(), timezone.utc), + ) + ) + audit( + db, + None, + "Event processing completed", + event.name, + organization_id=job.organization_id, + ) db.commit() except Exception as exc: db.rollback() @@ -239,46 +378,94 @@ def process_job(job_id: str) -> None: photo = db.get(Photo, job.photo_id) if job else None if job: retry_delays = [0, 30, 120, 600, 1800] - retryable = job.attempt < job.max_attempts + retryable = not isinstance(exc, PermanentJobError) and job.attempt < job.max_attempts job.status = "RETRY_SCHEDULED" if retryable else "DEAD_LETTERED" job.error = str(exc) - job.next_attempt_at = utcnow() + timedelta(seconds=retry_delays[min(job.attempt, len(retry_delays) - 1)]) if retryable else None + job.next_attempt_at = ( + utcnow() + timedelta(seconds=retry_delays[min(job.attempt, len(retry_delays) - 1)]) + if retryable + else None + ) job.completed_at = None if retryable else utcnow() if not retryable: - db.add(OutboxEvent( - aggregate_type="processing_job", - aggregate_id=job.id, - organization_id=job.organization_id, - event_type="fdx.v2.ml.process.dlq", - event_version=1, - correlation_id=job.correlation_id or job.id, - payload={"job_id": job.id, "media_id": job.photo_id, "error": str(exc)}, - )) + db.add( + OutboxEvent( + aggregate_type="processing_job", + aggregate_id=job.id, + organization_id=job.organization_id, + event_type="fdx.v2.ml.process.dlq", + event_version=1, + correlation_id=job.correlation_id or job.id, + payload={ + "job_id": job.id, + "media_id": job.photo_id, + "error": str(exc), + }, + ) + ) if photo: photo.processing_status = "queued" if job and job.status == "RETRY_SCHEDULED" else "failed" - audit(db, None, "Processing job failed", f"{job_id}: {exc}", "danger", job.organization_id if job else None) + audit( + db, + None, + "Processing job failed", + f"{job_id}: {exc}", + "danger", + job.organization_id if job else None, + ) db.commit() def run_retention() -> None: with SessionLocal() as db: - expired_exports = db.scalars(select(GalleryExport).where(GalleryExport.expires_at <= utcnow(), GalleryExport.storage_key.is_not(None))).all() + expired_exports = db.scalars( + select(GalleryExport).where( + GalleryExport.expires_at <= utcnow(), + GalleryExport.storage_key.is_not(None), + ) + ).all() for item in expired_exports: storage.delete(item.storage_key) organization = db.get(Organization, item.organization_id) if organization: organization.storage_used_bytes = max(0, organization.storage_used_bytes - item.size_bytes) - db.add(StorageUsageLedger(organization_id=item.organization_id, event_id=item.event_id, operation="DELETE", bytes=-item.size_bytes)) + db.add( + StorageUsageLedger( + organization_id=item.organization_id, + event_id=item.event_id, + operation="DELETE", + bytes=-item.size_bytes, + ) + ) item.storage_key = None item.size_bytes = 0 item.status = "EXPIRED" - expired_organizations = db.scalars(select(Organization).where(Organization.expires_at < datetime.now(timezone.utc).date(), Organization.status == "active")).all() + expired_organizations = db.scalars( + select(Organization).where( + Organization.expires_at < datetime.now(timezone.utc).date(), + Organization.status == "active", + ) + ).all() for organization in expired_organizations: organization.status = "expired" user_ids = select(User.id).where(User.organization_id == organization.id) - db.query(RefreshSession).filter(RefreshSession.user_id.in_(user_ids), RefreshSession.revoked_at.is_(None)).update({"revoked_at": utcnow()}, synchronize_session=False) - audit(db, None, "Organization account expired", organization.name, organization_id=organization.id) - expired_events = db.scalars(select(Event).where((Event.expires_at <= date.today()) | (Event.status.in_(["DELETION_PENDING", "deletion_pending"])), Event.status.notin_(["expired", "DELETED"]))).all() + db.query(RefreshSession).filter( + RefreshSession.user_id.in_(user_ids), + RefreshSession.revoked_at.is_(None), + ).update({"revoked_at": utcnow()}, synchronize_session=False) + audit( + db, + None, + "Organization account expired", + organization.name, + organization_id=organization.id, + ) + expired_events = db.scalars( + select(Event).where( + (Event.expires_at <= date.today()) | (Event.status.in_(["DELETION_PENDING", "deletion_pending"])), + Event.status.notin_(["expired", "DELETED"]), + ) + ).all() for event in expired_events: deletion_requested = event.status.upper() == "DELETION_PENDING" photos = db.scalars(select(Photo).where(Photo.event_id == event.id)).all() @@ -287,28 +474,68 @@ def run_retention() -> None: storage.delete(photo.storage_key) if photo.thumbnail_storage_key: storage.delete(photo.thumbnail_storage_key) - enrollments = db.scalars(select(FaceEnrollment).join(Participant).where(Participant.event_id == event.id)).all() + enrollments = db.scalars( + select(FaceEnrollment).join(Participant).where(Participant.event_id == event.id) + ).all() for enrollment in enrollments: storage.delete(enrollment.storage_key) released += sum(enrollment.size_bytes for enrollment in enrollments) - exports = db.scalars(select(GalleryExport).where(GalleryExport.event_id == event.id, GalleryExport.storage_key.is_not(None))).all() + pending_tokens = db.scalars( + select(ParticipantEnrollmentToken) + .join(Participant) + .where( + Participant.event_id == event.id, + ParticipantEnrollmentToken.pending_storage_key.is_not(None), + ) + ).all() + for token in pending_tokens: + storage.delete(token.pending_storage_key) + exports = db.scalars( + select(GalleryExport).where( + GalleryExport.event_id == event.id, + GalleryExport.storage_key.is_not(None), + ) + ).all() for export in exports: storage.delete(export.storage_key) released += sum(export.size_bytes for export in exports) if photos: db.execute(delete(Photo).where(Photo.id.in_([photo.id for photo in photos]))) if enrollments: - db.execute(delete(FaceEnrollment).where(FaceEnrollment.id.in_([enrollment.id for enrollment in enrollments]))) + db.execute( + delete(FaceEnrollment).where(FaceEnrollment.id.in_([enrollment.id for enrollment in enrollments])) + ) db.execute(delete(Participant).where(Participant.event_id == event.id)) organization = db.get(Organization, event.organization_id) organization.storage_used_bytes = max(0, organization.storage_used_bytes - released) - db.add(StorageUsageLedger(organization_id=organization.id, event_id=event.id, operation="DELETE", bytes=-released)) + db.add( + StorageUsageLedger( + organization_id=organization.id, + event_id=event.id, + operation="DELETE", + bytes=-released, + ) + ) event.status = "DELETED" if deletion_requested else "expired" - audit(db, None, "Retention cleanup completed", f"{event.name}: {len(photos)} photos removed", organization_id=event.organization_id) + audit( + db, + None, + "Retention cleanup completed", + f"{event.name}: {len(photos)} photos removed", + organization_id=event.organization_id, + ) db.flush() deleting_organizations = db.scalars(select(Organization).where(Organization.status == "deletion_pending")).all() for organization in deleting_organizations: - active_events = db.scalar(select(func.count(Event.id)).where(Event.organization_id == organization.id, Event.status.notin_(["DELETED", "expired"]))) or 0 + active_events = ( + db.scalar( + select(func.count(Event.id)).where( + Event.organization_id == organization.id, + Event.status.notin_(["DELETED", "expired"]), + ) + ) + or 0 + ) if active_events: continue # Remove event-owned imports and workflow records first so their @@ -318,7 +545,13 @@ def run_retention() -> None: db.execute(delete(User).where(User.organization_id == organization.id)) organization.status = "deleted" organization.storage_used_bytes = 0 - audit(db, None, "Organization deletion completed", organization.name, organization_id=organization.id) + audit( + db, + None, + "Organization deletion completed", + organization.name, + organization_id=organization.id, + ) db.commit() @@ -332,14 +565,29 @@ def main() -> None: last_outbox_poll = 0.0 last_reservation_poll = 0.0 last_notification_poll = 0.0 + last_recovery_poll = 0.0 + last_reconciliation = 0.0 while True: now = time.monotonic() if consumer is None and now - last_consumer_attempt > 5: last_consumer_attempt = now try: - consumer = KafkaConsumer(settings.kafka_topic, "fdx.v2.ml.process.requested", "fdx.v2.gallery.export.requested", bootstrap_servers=settings.kafka_bootstrap_servers.split(","), security_protocol=settings.kafka_security_protocol, group_id="fdx-workers", auto_offset_reset="earliest", enable_auto_commit=True, value_deserializer=lambda value: __import__("json").loads(value.decode())) + consumer = KafkaConsumer( + settings.kafka_topic, + "fdx.v2.ml.process.requested", + "fdx.v2.gallery.export.requested", + bootstrap_servers=settings.kafka_bootstrap_servers.split(","), + security_protocol=settings.kafka_security_protocol, + group_id="fdx-workers", + auto_offset_reset="earliest", + enable_auto_commit=True, + value_deserializer=lambda value: __import__("json").loads(value.decode()), + ) except KafkaError: - print("Waiting for Kafka; PostgreSQL fallback remains active...", flush=True) + print( + "Waiting for Kafka; PostgreSQL fallback remains active...", + flush=True, + ) if settings.retention_scheduler_enabled and now - last_retention > settings.retention_poll_seconds: run_retention() last_retention = now @@ -358,6 +606,12 @@ def main() -> None: if now - last_notification_poll > 3600: send_scheduled_notifications() last_notification_poll = now + if now - last_recovery_poll > 60: + recover_stuck_jobs() + last_recovery_poll = now + if now - last_reconciliation > 86400: + reconcile_storage_usage() + last_reconciliation = now if consumer is None: time.sleep(1) continue @@ -377,7 +631,16 @@ def main() -> None: def retry_failed_emails() -> None: with SessionLocal() as db: - pending = db.scalars(select(EmailOutbox).where(EmailOutbox.status == "failed", EmailOutbox.attempts < settings.email_max_attempts, EmailOutbox.next_attempt_at <= utcnow()).order_by(EmailOutbox.next_attempt_at).limit(25)).all() + pending = db.scalars( + select(EmailOutbox) + .where( + EmailOutbox.status == "failed", + EmailOutbox.attempts < settings.email_max_attempts, + EmailOutbox.next_attempt_at <= utcnow(), + ) + .order_by(EmailOutbox.next_attempt_at) + .limit(25) + ).all() for item in pending: dispatch_email(db, item) db.commit() @@ -389,17 +652,21 @@ def send_scheduled_notifications() -> None: today = utcnow().date() def send_once(organization_id: str, recipient: str, subject: str, html: str) -> None: - existing = db.scalar(select(EmailOutbox.id).where(EmailOutbox.recipient == recipient, EmailOutbox.subject == subject)) + existing = db.scalar( + select(EmailOutbox.id).where(EmailOutbox.recipient == recipient, EmailOutbox.subject == subject) + ) if existing: return item = queue_email(db, organization_id, recipient, subject, html) dispatch_email(db, item) - reminders = db.scalars(select(Participant).where( - Participant.enrollment_status.in_(["invited", "opened"]), - Participant.enrollment_expires_at > utcnow(), - Participant.created_at <= utcnow() - timedelta(hours=24), - )).all() + reminders = db.scalars( + select(Participant).where( + Participant.enrollment_status.in_(["invited", "opened"]), + Participant.enrollment_expires_at > utcnow(), + Participant.created_at <= utcnow() - timedelta(hours=24), + ) + ).all() for participant in reminders: send_once( participant.organization_id, @@ -414,28 +681,70 @@ def send_once(organization_id: str, recipient: str, subject: str, html: str) -> if organization.expires_at: days = (organization.expires_at - today).days if days in {7, 1}: - send_once(organization.id, administrator.email, f"FDX account expires in {days} day(s) [{organization.id}]", "

Your Organization's FDX access is approaching its configured expiry date.

") - events = db.scalars(select(Event).where(Event.organization_id == organization.id, Event.expires_at.in_([today + timedelta(days=7), today + timedelta(days=1)]), Event.status.notin_(["expired", "DELETED"]))).all() + send_once( + organization.id, + administrator.email, + f"FDX account expires in {days} day(s) [{organization.id}]", + "

Your Organization's FDX access is approaching its configured expiry date.

", + ) + events = db.scalars( + select(Event).where( + Event.organization_id == organization.id, + Event.expires_at.in_([today + timedelta(days=7), today + timedelta(days=1)]), + Event.status.notin_(["expired", "DELETED"]), + ) + ).all() for event in events: days = (event.expires_at - today).days - send_once(organization.id, administrator.email, f"Event data expires in {days} day(s) [{event.id}]", f"

Media and biometric data for {event.name} will be removed by its retention policy.

") - permanent_failures = db.scalars(select(EmailOutbox).where(EmailOutbox.organization_id == organization.id, EmailOutbox.status == "failed", EmailOutbox.attempts >= settings.email_max_attempts)).all() + send_once( + organization.id, + administrator.email, + f"Event data expires in {days} day(s) [{event.id}]", + f"

Media and biometric data for {event.name} will be removed by its retention policy.

", + ) + permanent_failures = db.scalars( + select(EmailOutbox).where( + EmailOutbox.organization_id == organization.id, + EmailOutbox.status == "failed", + EmailOutbox.attempts >= settings.email_max_attempts, + ) + ).all() for failure in permanent_failures: - send_once(organization.id, administrator.email, f"FDX email delivery requires attention [{failure.id}]", f"

A message to {failure.recipient} could not be delivered after bounded retries.

") + send_once( + organization.id, + administrator.email, + f"FDX email delivery requires attention [{failure.id}]", + f"

A message to {failure.recipient} could not be delivered after bounded retries.

", + ) db.commit() def process_pending_jobs() -> None: """Use PostgreSQL as a durable fallback when Kafka publication is interrupted.""" with SessionLocal() as db: - job_ids = db.scalars(select(ProcessingJob.id).where(ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]), (ProcessingJob.next_attempt_at.is_(None)) | (ProcessingJob.next_attempt_at <= utcnow()), ProcessingJob.created_at <= utcnow() - timedelta(seconds=5)).order_by(ProcessingJob.created_at).limit(10)).all() + job_ids = db.scalars( + select(ProcessingJob.id) + .where( + ProcessingJob.status.in_(["queued", "RETRY_SCHEDULED"]), + (ProcessingJob.next_attempt_at.is_(None)) | (ProcessingJob.next_attempt_at <= utcnow()), + ProcessingJob.created_at <= utcnow() - timedelta(seconds=5), + ) + .order_by(ProcessingJob.created_at) + .limit(10) + ).all() for job_id in job_ids: process_job(job_id) def publish_outbox_events() -> None: with SessionLocal() as db: - rows = db.scalars(select(OutboxEvent).where(OutboxEvent.published_at.is_(None)).order_by(OutboxEvent.created_at).with_for_update(skip_locked=True).limit(50)).all() + rows = db.scalars( + select(OutboxEvent) + .where(OutboxEvent.published_at.is_(None)) + .order_by(OutboxEvent.created_at) + .with_for_update(skip_locked=True) + .limit(50) + ).all() for row in rows: envelope = { "event_id": row.id, @@ -460,17 +769,127 @@ def publish_outbox_events() -> None: def expire_storage_reservations() -> None: with SessionLocal() as db: - rows = db.scalars(select(StorageReservation).where(StorageReservation.status == "RESERVED", StorageReservation.expires_at <= utcnow()).with_for_update(skip_locked=True)).all() + rows = db.scalars( + select(StorageReservation) + .where( + StorageReservation.status == "RESERVED", + StorageReservation.expires_at <= utcnow(), + ) + .with_for_update(skip_locked=True) + ).all() for row in rows: row.status = "EXPIRED" - db.add(StorageUsageLedger(organization_id=row.organization_id, event_id=row.event_id, operation="RELEASE", bytes=row.bytes)) + db.add( + StorageUsageLedger( + organization_id=row.organization_id, + event_id=row.event_id, + operation="RELEASE", + bytes=row.bytes, + ) + ) batch = db.get(UploadBatch, row.upload_batch_id) if batch and batch.status not in {"COMPLETE", "CANCELLED"}: batch.status = "CANCELLED" for record in batch.manifest or []: + if record.get("multipart_upload_id") and not record.get("multipart_completed"): + storage.abort_multipart_upload(record["storage_key"], record["multipart_upload_id"]) storage.delete(record["storage_key"]) db.commit() +def recover_stuck_jobs() -> None: + """Return abandoned work to the bounded retry queue.""" + with SessionLocal() as db: + cutoff = utcnow() - timedelta(minutes=10) + rows = db.scalars( + select(ProcessingJob) + .where( + ProcessingJob.status.in_(["processing", "CANCEL_REQUESTED"]), + ProcessingJob.heartbeat_at < cutoff, + ) + .with_for_update(skip_locked=True) + ).all() + for job in rows: + if job.status == "CANCEL_REQUESTED": + job.status = "CANCELLED" + job.completed_at = utcnow() + elif job.attempt < job.max_attempts: + job.status = "RETRY_SCHEDULED" + job.next_attempt_at = utcnow() + job.error = "Worker heartbeat expired; job recovered" + else: + job.status = "DEAD_LETTERED" + job.completed_at = utcnow() + job.error = "Worker heartbeat expired after maximum attempts" + if job.photo_id: + photo = db.get(Photo, job.photo_id) + if photo: + photo.processing_status = ( + "uploaded" + if job.status == "CANCELLED" + else "queued" + if job.status == "RETRY_SCHEDULED" + else "failed" + ) + audit( + db, + None, + "Processing job recovered", + f"{job.id}: {job.status}", + organization_id=job.organization_id, + ) + db.commit() + + +def reconcile_storage_usage() -> None: + """Rebuild tenant storage counters from authoritative database records.""" + with SessionLocal() as db: + for organization in db.scalars(select(Organization)).all(): + media_bytes = ( + db.scalar( + select(func.coalesce(func.sum(Photo.size_bytes + Photo.thumbnail_size_bytes), 0)).where( + Photo.organization_id == organization.id + ) + ) + or 0 + ) + enrollment_bytes = ( + db.scalar( + select(func.coalesce(func.sum(FaceEnrollment.size_bytes), 0)).where( + FaceEnrollment.organization_id == organization.id + ) + ) + or 0 + ) + export_bytes = ( + db.scalar( + select(func.coalesce(func.sum(GalleryExport.size_bytes), 0)).where( + GalleryExport.organization_id == organization.id, + GalleryExport.storage_key.is_not(None), + ) + ) + or 0 + ) + actual = int(media_bytes + enrollment_bytes + export_bytes) + delta = actual - organization.storage_used_bytes + if delta: + organization.storage_used_bytes = actual + db.add( + StorageUsageLedger( + organization_id=organization.id, + operation="RECONCILE", + bytes=delta, + ) + ) + audit( + db, + None, + "Storage usage reconciled", + f"adjusted by {delta} bytes", + organization_id=organization.id, + ) + db.commit() + + if __name__ == "__main__": main() diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py index 4c632d2..6bca0db 100644 --- a/backend/tests/test_security.py +++ b/backend/tests/test_security.py @@ -32,12 +32,34 @@ def test_cosine_similarity_is_bounded_for_normalized_embeddings(): def test_cross_tenant_event_endpoints_return_not_found(): suffix = new_opaque_token()[0][:12] with SessionLocal() as db: - tenant_a = Organization(name=f"Tenant A {suffix}", type=OrganizationType.COMPANY, contact_email=f"a-{suffix}@example.com") - tenant_b = Organization(name=f"Tenant B {suffix}", type=OrganizationType.COMPANY, contact_email=f"b-{suffix}@example.com") + tenant_a = Organization( + name=f"Tenant A {suffix}", + type=OrganizationType.COMPANY, + contact_email=f"a-{suffix}@example.com", + ) + tenant_b = Organization( + name=f"Tenant B {suffix}", + type=OrganizationType.COMPANY, + contact_email=f"b-{suffix}@example.com", + ) db.add_all([tenant_a, tenant_b]) db.flush() - user_a = User(organization_id=tenant_a.id, name="Admin A", email=f"admin-a-{suffix}@example.com", password_hash=hash_password("Correct Horse Battery Staple"), role=UserRole.ORG_ADMIN, status="active") - event_b = Event(organization_id=tenant_b.id, name=f"Private event {suffix}", event_date=date.today(), retention_days=30, expires_at=date.today() + timedelta(days=30), status="DRAFT") + user_a = User( + organization_id=tenant_a.id, + name="Admin A", + email=f"admin-a-{suffix}@example.com", + password_hash=hash_password("Correct Horse Battery Staple"), + role=UserRole.ORG_ADMIN, + status="active", + ) + event_b = Event( + organization_id=tenant_b.id, + name=f"Private event {suffix}", + event_date=date.today(), + retention_days=30, + expires_at=date.today() + timedelta(days=30), + status="DRAFT", + ) db.add_all([user_a, event_b]) db.commit() token = token_pair(user_a)["access_token"] @@ -47,8 +69,22 @@ def test_cross_tenant_event_endpoints_return_not_found(): headers = {"Authorization": f"Bearer {token}"} with TestClient(app) as client: assert client.get(f"/api/v2/events/{event_id}", headers=headers).status_code == 404 - assert client.patch(f"/api/v2/events/{event_id}", headers=headers, json={"name": "Forbidden"}).status_code == 404 - assert client.post(f"/api/v2/events/{event_id}/upload-batches", headers=headers, json={"expected_files": 1, "reserved_bytes": 1024}).status_code == 404 + assert ( + client.patch( + f"/api/v2/events/{event_id}", + headers=headers, + json={"name": "Forbidden"}, + ).status_code + == 404 + ) + assert ( + client.post( + f"/api/v2/events/{event_id}/upload-batches", + headers=headers, + json={"expected_files": 1, "reserved_bytes": 1024}, + ).status_code + == 404 + ) with SessionLocal() as db: tenant_b_id = db.get(Event, event_id).organization_id diff --git a/deploy/aws/platform.yml b/deploy/aws/platform.yml index 9eb0d19..b23acc1 100644 --- a/deploy/aws/platform.yml +++ b/deploy/aws/platform.yml @@ -110,6 +110,13 @@ Resources: BucketEncryption: { ServerSideEncryptionConfiguration: [{ ServerSideEncryptionByDefault: { SSEAlgorithm: AES256 } }] } PublicAccessBlockConfiguration: { BlockPublicAcls: true, BlockPublicPolicy: true, IgnorePublicAcls: true, RestrictPublicBuckets: true } VersioningConfiguration: { Status: Enabled } + CorsConfiguration: + CorsRules: + - AllowedHeaders: ["*"] + AllowedMethods: [GET, PUT] + AllowedOrigins: [!Ref DomainName] + ExposedHeaders: [ETag] + MaxAge: 3600 LifecycleConfiguration: Rules: - Id: ArchiveEventMedia @@ -118,6 +125,21 @@ Resources: Transitions: [{ StorageClass: GLACIER_IR, TransitionInDays: !Ref ArchiveAfterDays }] ExpirationInDays: !Ref DeleteAfterDays NoncurrentVersionExpiration: { NoncurrentDays: 30 } + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 } + + MediaBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref MediaBucket + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: DenyInsecureTransport + Effect: Deny + Principal: "*" + Action: s3:* + Resource: [!GetAtt MediaBucket.Arn, !Sub '${MediaBucket.Arn}/*'] + Condition: { Bool: { "aws:SecureTransport": "false" } } ApiRepository: { Type: AWS::ECR::Repository, Properties: { ImageScanningConfiguration: { ScanOnPush: true }, ImageTagMutability: MUTABLE } } WebRepository: { Type: AWS::ECR::Repository, Properties: { ImageScanningConfiguration: { ScanOnPush: true }, ImageTagMutability: MUTABLE } } MlRepository: { Type: AWS::ECR::Repository, Properties: { ImageScanningConfiguration: { ScanOnPush: true }, ImageTagMutability: MUTABLE } } diff --git a/deploy/aws/publish.sh b/deploy/aws/publish.sh index 154ab78..c394bdd 100755 --- a/deploy/aws/publish.sh +++ b/deploy/aws/publish.sh @@ -3,7 +3,7 @@ set -eu STACK_NAME=${1:-fdx-production} AWS_DEPLOY_REGION=${2:-${AWS_REGION:-ap-south-1}} -PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +PROJECT_ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) stack_output() { aws cloudformation describe-stacks \ diff --git a/docker-compose.aws.yml b/docker-compose.aws.yml index bd7fd73..7776c8f 100644 --- a/docker-compose.aws.yml +++ b/docker-compose.aws.yml @@ -22,7 +22,7 @@ services: ml: image: ${ML_IMAGE} environment: - FDX_DEVICE: cpu + FDX_DEVICE: ${FDX_DEVICE:-cpu} ML_WARMUP_ON_IMPORT: "1" volumes: - /opt/fdx/face-processing/models:/app/models:ro diff --git a/docs/spec-implementation.md b/docs/spec-implementation.md index f927f42..b58ef18 100644 --- a/docs/spec-implementation.md +++ b/docs/spec-implementation.md @@ -10,17 +10,17 @@ This document maps the authoritative requirements in [`specs.md`](specs.md) to e | Tenant isolation | Organization identity is taken only from the authenticated user. Tenant-owned event, participant, media, job, match, gallery, delivery, usage, and log queries include that tenant. The mandatory cross-tenant GET/PATCH/presign test is automated. | | Organizations and users | Super Admin dashboard, organization lifecycle, storage/retention/account-expiry policy, administrator invitation, activation/suspension, job visibility, and audit views. | | Events and participants | Backend event state machine; event lifecycle endpoints; CSV/XLS/XLSX/XLSM validation; invalid/duplicate preview; explicit idempotent confirmation; participant CRUD and invitation delivery. | -| Public enrollment | High-entropy hashed single-use tokens, expiry, Redis/NGINX rate limits, explicit versioned consent record, image validation, RetinaFace/AdaFace enrollment, 512-value pgvector-compatible embedding, quota accounting, and token consumption. | -| Uploads and private media | Storage reservation, quota lock, manifest/checksum, direct presigned S3 PUT (local authenticated fallback), completion verification, duplicate hash suppression, private object keys, asynchronous WebP thumbnails, signed reads, and usage ledger. | -| Kafka/outbox/jobs | Transactional outbox, versioned correlation envelope, idempotent locked consumers, PostgreSQL fallback queue, bounded exponential retry, heartbeat/progress, dead-letter state, manual retry, and failure visibility. | -| ML and matching | RetinaFace R50 + AdaFace IR101, checksum/model registry, normalized 512-dimensional embeddings, cosine score, runner-up margin, configurable auto/review thresholds, model/threshold reproducibility fields, manual confirm/reject audit. | +| Public enrollment | High-entropy hashed single-use tokens, expiry, Redis/NGINX rate limits, explicit versioned consent record, browser-to-S3 presigned upload with a private local fallback, size/pixel/magic-byte/face-quality validation, RetinaFace/AdaFace enrollment, 512-value pgvector-compatible embedding, quota accounting, replacement cleanup, and token consumption. | +| Uploads and private media | Storage reservation, quota lock, chunked manifests, checksum and type validation, bounded browser concurrency, single-part and multipart presigned S3 uploads (local authenticated fallback), completion verification, duplicate hash suppression, private object keys, asynchronous WebP thumbnails, signed reads, and usage ledger. | +| Kafka/outbox/jobs | Transactional outbox, versioned correlation envelope, idempotent locked consumers, PostgreSQL fallback queue, bounded exponential retry, heartbeat/progress, stuck-job recovery, cooperative event cancellation, dead-letter state, manual retry, and tenant/global job visibility. | +| ML and matching | RetinaFace R50 + AdaFace IR101, checksum/model registry, normalized 512-dimensional embeddings, cosine score, runner-up margin, configurable auto/review thresholds, rejected/low-resolution face policy, model/threshold reproducibility fields, match detail/filtering, and manual confirm/reject audit. | | Galleries and delivery | Tenant-scoped gallery construction, expiring hashed gallery token, authorized per-photo signed download, provider-backed result email, delivery/webhook status, and worker-generated expiring private ZIP export. | | Email | Provider adapter for Resend, SES, or persistent development outbox; invitation, enrollment, reminder, result, password reset, account/event-expiry warning, bounded retry, delivery-failure alert, and signed idempotent webhook ingestion. | -| Retention/deletion | Scheduled reservation expiry, event/account expiry, asynchronous event/organization deletion, session/link invalidation through parent lifecycle, originals/thumbnails/exports/enrollments removal, storage release, and audit trail. | +| Retention/deletion | Scheduled reservation expiry, orphaned multipart cleanup, event/account expiry, asynchronous event/organization deletion, immediate session/link invalidation through parent lifecycle, originals/thumbnails/exports/enrollments/pending uploads removal, periodic storage reconciliation, storage release, and audit trail. | | Observability/security | Request/correlation IDs, redacted route-template JSON logs, Prometheus-format API counters, dependency probes, CSP/HSTS/content-type/referrer/permissions headers, CORS allowlist, and V2 error envelope. | | Frontend | In-memory access token plus refresh cookie, one login and role routing, forgot/reset/invite pages, import preview/confirm, direct folder upload through V2, public V2 enrollment, private thumbnail gallery, per-photo download, and async Download All status. | -| Deployment | Dockerized web/API/worker/Gunicorn ML, NGINX routing/rate limits, pgvector PostgreSQL, local Compose, and AWS CloudFormation for ALB/ASG/EC2/RDS/ElastiCache/MSK/S3/Glacier/SES/Secrets Manager/IAM/EventBridge/Lambda. | -| CI and migrations | Forward Alembic migrations, pgvector migration service, frontend lint/build/audit, backend lint/compile/tests/dependency audit, Compose/CloudFormation/shell validation, and container builds. Production does not auto-create schema. | +| Deployment | Dockerized web/API/worker/Gunicorn ML, CPU and NVIDIA CUDA ML images, NGINX routing/rate limits, pgvector PostgreSQL, local Compose, and AWS CloudFormation for ALB/ASG/EC2/RDS/ElastiCache/MSK/S3/Glacier/SES/Secrets Manager/IAM/EventBridge/Lambda. S3 enforces private access, TLS, encryption, browser CORS, multipart cleanup, and lifecycle policy. | +| CI and migrations | Forward Alembic migrations, pgvector migration service, Prettier and frontend lint/build/audit, Ruff lint/format/compile/tests/dependency audit, Compose/CloudFormation/ShellCheck validation, and container builds. Production does not auto-create schema. | ## Automated acceptance evidence @@ -31,8 +31,13 @@ This document maps the authoritative requirements in [`specs.md`](specs.md) to e - invalid import preview and idempotent confirmation; - consent, one-time enrollment, real 512-dimensional ML enrollment; - checksummed direct upload and idempotent completion; +- bulk invitations and participant direct-upload enrollment; +- global job detail, match detail/filtering, queued-job cancellation, and the exact Download All contract; - real detection/matching, private gallery isolation, signed download, and asynchronous ZIP output. +The 2026-08-13 verification run also passed Ruff lint/format, Python compilation, four backend security tests, Prettier, Oxlint, the Vite production build, npm audit, pip-audit, ShellCheck, shell syntax checks, Compose configuration, CloudFormation lint, migration `20260813_05`, both live workflow suites, and both ONNX checksum checks. +An automated OpenAPI comparison found every API method/path declared in sections 52–63 of `specs.md` (the apparent `POST /api/v2/auth/login]` mismatch is only the Markdown link-closing bracket) and 96 implemented V2 operations in total. + `tools/verify_platform.mjs` preserves regression coverage for the original dashboard API, restricted Staff permissions, Excel compatibility, secure thumbnails, email state, event details, deletion, and storage release. `backend/tests` runs unit and API tenant-isolation tests in CI. ## Production release gates diff --git a/face-processing/service/Dockerfile.gpu b/face-processing/service/Dockerfile.gpu new file mode 100644 index 0000000..fe7af62 --- /dev/null +++ b/face-processing/service/Dockerfile.gpu @@ -0,0 +1,17 @@ +FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FDX_DEVICE=cuda \ + ML_WARMUP_ON_IMPORT=1 + +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-pip libgl1 libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* +COPY face-processing/service/requirements.txt /app/requirements.txt +RUN python3 -m pip install --break-system-packages --no-cache-dir -r /app/requirements.txt onnxruntime-gpu==1.21.1 +COPY tools/native_accurate_backend.py /app/tools/native_accurate_backend.py +COPY face-processing/service/assets /app/face-processing/service/assets +CMD ["gunicorn", "--bind", "0.0.0.0:3000", "--workers", "1", "--threads", "4", "--timeout", "300", "--graceful-timeout", "30", "--access-logfile", "-", "--error-logfile", "-", "tools.native_accurate_backend:app"] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..2276b4b --- /dev/null +++ b/ruff.toml @@ -0,0 +1,6 @@ +target-version = "py312" +line-length = 120 + +[lint] +select = ["E4", "E7", "E9", "F", "I"] + diff --git a/tools/verify_v2.mjs b/tools/verify_v2.mjs index 3e10ae0..a83a4ff 100644 --- a/tools/verify_v2.mjs +++ b/tools/verify_v2.mjs @@ -4,25 +4,38 @@ import { readFileSync } from "node:fs"; const origin = process.env.FDX_ORIGIN || "http://127.0.0.1:8080"; const base = `${origin}/api/v2`; const facePath = process.env.FDX_VERIFY_FACE_IMAGE; -if (!facePath) throw new Error("FDX_VERIFY_FACE_IMAGE must point to a clear JPEG face image"); +if (!facePath) + throw new Error( + "FDX_VERIFY_FACE_IMAGE must point to a clear JPEG face image", + ); const face = readFileSync(facePath); +const faceDigest = createHash("sha256").update(face).digest("hex"); function cookie(response) { return response.headers.get("set-cookie")?.split(";", 1)[0] || ""; } -async function call(path, { token, cookie: sessionCookie, expected = 200, ...options } = {}) { +async function call( + path, + { token, cookie: sessionCookie, expected = 200, ...options } = {}, +) { const headers = new Headers(options.headers || {}); if (token) headers.set("authorization", `Bearer ${token}`); if (sessionCookie) headers.set("cookie", sessionCookie); - if (options.body && typeof options.body === "string" && !headers.has("content-type")) { + if ( + options.body && + typeof options.body === "string" && + !headers.has("content-type") + ) { headers.set("content-type", "application/json"); } const response = await fetch(`${base}${path}`, { ...options, headers }); const text = response.status === 204 ? "" : await response.text(); const payload = text ? JSON.parse(text) : null; if (response.status !== expected) { - throw new Error(`${options.method || "GET"} ${path}: expected ${expected}, received ${response.status}: ${text}`); + throw new Error( + `${options.method || "GET"} ${path}: expected ${expected}, received ${response.status}: ${text}`, + ); } return { response, payload, data: payload?.data }; } @@ -30,15 +43,25 @@ async function call(path, { token, cookie: sessionCookie, expected = 200, ...opt const suffix = Date.now(); const login = await call("/auth/login", { method: "POST", - body: JSON.stringify({ email: process.env.FDX_SUPER_ADMIN_EMAIL || "superadmin@fdx.io", password: process.env.FDX_SUPER_ADMIN_PASSWORD || "SuperAdmin@123" }), + body: JSON.stringify({ + email: process.env.FDX_SUPER_ADMIN_EMAIL || "superadmin@fdx.io", + password: process.env.FDX_SUPER_ADMIN_PASSWORD || "SuperAdmin@123", + }), }); const firstAccess = login.data.access_token; const firstRefresh = cookie(login.response); -const rotated = await call("/auth/refresh", { method: "POST", cookie: firstRefresh }); +const rotated = await call("/auth/refresh", { + method: "POST", + cookie: firstRefresh, +}); const adminToken = rotated.data.access_token; const adminRefresh = cookie(rotated.response); await call("/auth/me", { token: firstAccess, expected: 401 }); -await call("/auth/refresh", { method: "POST", cookie: firstRefresh, expected: 401 }); +await call("/auth/refresh", { + method: "POST", + cookie: firstRefresh, + expected: 401, +}); async function createOrganization(label) { const organization = await call("/admin/organizations", { @@ -54,13 +77,21 @@ async function createOrganization(label) { }), expected: 201, }); - const invitation = await call(`/admin/organizations/${organization.data.id}/users`, { - method: "POST", - token: adminToken, - body: JSON.stringify({ name: `${label} Admin`, email: `${label.toLowerCase()}-admin-${suffix}@example.com` }), - expected: 201, - }); - const invitationToken = invitation.data.development_invitation_url.split("/").pop(); + const invitation = await call( + `/admin/organizations/${organization.data.id}/users`, + { + method: "POST", + token: adminToken, + body: JSON.stringify({ + name: `${label} Admin`, + email: `${label.toLowerCase()}-admin-${suffix}@example.com`, + }), + expected: 201, + }, + ); + const invitationToken = invitation.data.development_invitation_url + .split("/") + .pop(); const accepted = await call(`/auth/invitations/${invitationToken}/accept`, { method: "POST", body: JSON.stringify({ password: "VerificationPass@123" }), @@ -70,7 +101,11 @@ async function createOrganization(label) { body: JSON.stringify({ password: "VerificationPass@123" }), expected: 404, }); - return { organization: organization.data, token: accepted.data.access_token, refresh: cookie(accepted.response) }; + return { + organization: organization.data, + token: accepted.data.access_token, + refresh: cookie(accepted.response), + }; } const tenantA = await createOrganization("Alpha"); @@ -79,7 +114,12 @@ const startsAt = new Date(Date.now() + 86_400_000).toISOString(); const event = await call("/events", { method: "POST", token: tenantA.token, - body: JSON.stringify({ name: `V2 verification ${suffix}`, description: "Automated V2 acceptance flow", starts_at: startsAt, retention_days: 30 }), + body: JSON.stringify({ + name: `V2 verification ${suffix}`, + description: "Automated V2 acceptance flow", + starts_at: startsAt, + retention_days: 30, + }), expected: 201, }); const eventId = event.data.id; @@ -88,103 +128,343 @@ for (const probe of [ ["PATCH", `/events/${eventId}`], ["POST", `/events/${eventId}/upload-batches`], ]) { - const body = probe[0] === "PATCH" ? JSON.stringify({ name: "Cross-tenant mutation" }) : probe[0] === "POST" ? JSON.stringify({ expected_files: 1, reserved_bytes: face.length }) : undefined; - await call(probe[1], { method: probe[0], token: tenantB.token, body, expected: 404 }); + const body = + probe[0] === "PATCH" + ? JSON.stringify({ name: "Cross-tenant mutation" }) + : probe[0] === "POST" + ? JSON.stringify({ expected_files: 1, reserved_bytes: face.length }) + : undefined; + await call(probe[1], { + method: probe[0], + token: tenantB.token, + body, + expected: 404, + }); } -await call(`/events/${eventId}/open-enrollment`, { method: "POST", token: tenantA.token }); +await call(`/events/${eventId}/open-enrollment`, { + method: "POST", + token: tenantA.token, +}); const participantFile = new FormData(); -participantFile.append("file", new Blob([`Name,Email\nV2 Participant,participant-${suffix}@example.com\nBroken,invalid-email\n`], { type: "text/csv" }), "participants.csv"); -const preview = await call(`/events/${eventId}/participant-imports`, { method: "POST", token: tenantA.token, body: participantFile, expected: 201 }); -if (preview.data.valid_rows !== 1 || preview.data.invalid_rows !== 1) throw new Error("Participant preview validation did not classify rows correctly"); +participantFile.append( + "file", + new Blob( + [ + `Name,Email\nV2 Participant,participant-${suffix}@example.com\nBroken,invalid-email\n`, + ], + { type: "text/csv" }, + ), + "participants.csv", +); +const preview = await call(`/events/${eventId}/participant-imports`, { + method: "POST", + token: tenantA.token, + body: participantFile, + expected: 201, +}); +if (preview.data.valid_rows !== 1 || preview.data.invalid_rows !== 1) + throw new Error( + "Participant preview validation did not classify rows correctly", + ); const importKey = randomUUID(); -const confirmed = await call(`/events/${eventId}/participant-imports/${preview.data.id}/confirm`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": importKey }, expected: 201 }); -const repeatedConfirm = await call(`/events/${eventId}/participant-imports/${preview.data.id}/confirm`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": importKey }, expected: 201 }); -if (confirmed.data.participants_created !== 1 || repeatedConfirm.data.participants_created !== 1) throw new Error("Import confirmation idempotency failed"); -const enrollmentToken = confirmed.data.development_invitations[0].url.split("/").pop(); +const confirmed = await call( + `/events/${eventId}/participant-imports/${preview.data.id}/confirm`, + { + method: "POST", + token: tenantA.token, + headers: { "idempotency-key": importKey }, + expected: 201, + }, +); +const repeatedConfirm = await call( + `/events/${eventId}/participant-imports/${preview.data.id}/confirm`, + { + method: "POST", + token: tenantA.token, + headers: { "idempotency-key": importKey }, + expected: 201, + }, +); +if ( + confirmed.data.participants_created !== 1 || + repeatedConfirm.data.participants_created !== 1 +) + throw new Error("Import confirmation idempotency failed"); +const bulkInvites = await call(`/events/${eventId}/participants/send-invites`, { + method: "POST", + token: tenantA.token, + headers: { "idempotency-key": randomUUID() }, + body: JSON.stringify({ enrollment_status: ["invited"] }), + expected: 202, +}); +if (bulkInvites.data.invitations_queued !== 1) + throw new Error("Bulk participant invitation was not queued"); +const enrollmentToken = bulkInvites.data.development_invitations[0].url + .split("/") + .pop(); await call(`/public/enrollment/${enrollmentToken}`); const consent = new FormData(); consent.append("accepted", "true"); -await call(`/public/enrollment/${enrollmentToken}/consent`, { method: "POST", body: consent, expected: 201 }); -const selfie = new FormData(); -selfie.append("selfie", new Blob([face], { type: "image/jpeg" }), "face.jpg"); -const enrollment = await call(`/public/enrollment/${enrollmentToken}/complete`, { method: "POST", body: selfie }); -if (enrollment.data.embedding_dimension !== 512) throw new Error("Enrollment embedding was not 512-dimensional"); +await call(`/public/enrollment/${enrollmentToken}/consent`, { + method: "POST", + body: consent, + expected: 201, +}); +const enrollmentUpload = await call( + `/public/enrollment/${enrollmentToken}/upload-url`, + { + method: "POST", + body: JSON.stringify({ + filename: "face.jpg", + content_type: "image/jpeg", + size_bytes: face.length, + sha256: faceDigest, + }), + }, +); +const enrollmentUploadResponse = await fetch( + enrollmentUpload.data.upload_url.startsWith("http") + ? enrollmentUpload.data.upload_url + : `${origin}${enrollmentUpload.data.upload_url}`, + { + method: "PUT", + headers: enrollmentUpload.data.headers, + body: face, + }, +); +if (!enrollmentUploadResponse.ok) + throw new Error( + `Enrollment direct upload failed: ${enrollmentUploadResponse.status}`, + ); +const enrollment = await call( + `/public/enrollment/${enrollmentToken}/complete`, + { method: "POST" }, +); +if (enrollment.data.embedding_dimension !== 512) + throw new Error("Enrollment embedding was not 512-dimensional"); await call(`/public/enrollment/${enrollmentToken}`, { expected: 404 }); -await call(`/events/${eventId}/close-enrollment`, { method: "POST", token: tenantA.token }); -const batch = await call(`/events/${eventId}/upload-batches`, { +await call(`/events/${eventId}/close-enrollment`, { method: "POST", token: tenantA.token, - body: JSON.stringify({ expected_files: 1, reserved_bytes: face.length }), - expected: 201, }); -const digest = createHash("sha256").update(face).digest("hex"); -const presigned = await call(`/events/${eventId}/upload-batches/${batch.data.id}/presign`, { +const batch = await call(`/events/${eventId}/upload-batches`, { method: "POST", token: tenantA.token, - body: JSON.stringify({ files: [{ filename: "folder/face.jpg", content_type: "image/jpeg", size_bytes: face.length, sha256: digest }] }), + body: JSON.stringify({ expected_files: 1, reserved_bytes: face.length }), + expected: 201, }); +const presigned = await call( + `/events/${eventId}/upload-batches/${batch.data.id}/presign`, + { + method: "POST", + token: tenantA.token, + body: JSON.stringify({ + files: [ + { + filename: "folder/face.jpg", + content_type: "image/jpeg", + size_bytes: face.length, + sha256: faceDigest, + }, + ], + }), + }, +); const upload = presigned.data.files[0]; -const uploadResponse = await fetch(upload.upload_url.startsWith("http") ? upload.upload_url : `${origin}${upload.upload_url}`, { - method: "PUT", - headers: { ...upload.headers, authorization: `Bearer ${tenantA.token}` }, - body: face, -}); -if (!uploadResponse.ok) throw new Error(`Direct upload failed: ${uploadResponse.status}`); +const uploadResponse = await fetch( + upload.upload_url.startsWith("http") + ? upload.upload_url + : `${origin}${upload.upload_url}`, + { + method: "PUT", + headers: { ...upload.headers, authorization: `Bearer ${tenantA.token}` }, + body: face, + }, +); +if (!uploadResponse.ok) + throw new Error(`Direct upload failed: ${uploadResponse.status}`); const completeKey = randomUUID(); -const complete = await call(`/events/${eventId}/upload-batches/${batch.data.id}/complete`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": completeKey }, expected: 202 }); -const completeAgain = await call(`/events/${eventId}/upload-batches/${batch.data.id}/complete`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": completeKey }, expected: 202 }); -if (complete.data.jobs[0] !== completeAgain.data.jobs[0]) throw new Error("Upload completion idempotency failed"); +const complete = await call( + `/events/${eventId}/upload-batches/${batch.data.id}/complete`, + { + method: "POST", + token: tenantA.token, + headers: { "idempotency-key": completeKey }, + expected: 202, + }, +); +const completeAgain = await call( + `/events/${eventId}/upload-batches/${batch.data.id}/complete`, + { + method: "POST", + token: tenantA.token, + headers: { "idempotency-key": completeKey }, + expected: 202, + }, +); +if (complete.data.jobs[0] !== completeAgain.data.jobs[0]) + throw new Error("Upload completion idempotency failed"); +await call(`/admin/jobs/${complete.data.jobs[0]}`, { token: adminToken }); let processing; for (let attempt = 0; attempt < 60; attempt += 1) { - processing = await call(`/events/${eventId}/processing`, { token: tenantA.token }); + processing = await call(`/events/${eventId}/processing`, { + token: tenantA.token, + }); if (processing.data.progress_percent === 100) break; await new Promise((resolve) => setTimeout(resolve, 1000)); } -if (processing.data.progress_percent !== 100) throw new Error(`ML processing did not finish: ${JSON.stringify(processing.data)}`); -const matches = await call(`/events/${eventId}/matches`, { token: tenantA.token }); -if (!matches.data.some((item) => ["high", "approved"].includes(item.decision))) throw new Error("Identical enrollment/event image did not produce an accepted match"); -const galleryBuild = await call(`/events/${eventId}/galleries/build`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": randomUUID() }, expected: 202 }); -if (galleryBuild.data.galleries_ready !== 1) throw new Error("Gallery was not built"); -const delivery = await call(`/events/${eventId}/deliveries/send`, { method: "POST", token: tenantA.token, headers: { "idempotency-key": randomUUID() }, expected: 202 }); -const galleryToken = delivery.data.development_gallery_urls[0].url.split("/").pop(); +if (processing.data.progress_percent !== 100) + throw new Error( + `ML processing did not finish: ${JSON.stringify(processing.data)}`, + ); +const matches = await call(`/events/${eventId}/matches`, { + token: tenantA.token, +}); +if (!matches.data.some((item) => ["high", "approved"].includes(item.decision))) + throw new Error( + "Identical enrollment/event image did not produce an accepted match", + ); +const acceptedMatch = matches.data.find((item) => + ["high", "approved"].includes(item.decision), +); +await call(`/events/${eventId}/matches/${acceptedMatch.id}`, { + token: tenantA.token, +}); +const filteredMatches = await call( + `/events/${eventId}/matches?media_id=${acceptedMatch.media_id}&review_required=false`, + { token: tenantA.token }, +); +if (!filteredMatches.data.some((item) => item.id === acceptedMatch.id)) + throw new Error( + "Match media/review filters did not return the expected result", + ); +const reprocess = await call( + `/events/${eventId}/media/${acceptedMatch.media_id}/reprocess`, + { method: "POST", token: tenantA.token, expected: 202 }, +); +const cancelled = await call(`/events/${eventId}/cancel-processing`, { + method: "POST", + token: tenantA.token, + expected: 202, +}); +if (cancelled.data.cancelled < 1) + throw new Error("Queued event processing was not cancelled"); +const cancelledJob = await call( + `/events/${eventId}/processing/jobs/${reprocess.data.job_id}`, + { token: tenantA.token }, +); +if (cancelledJob.data.status !== "CANCELLED") + throw new Error("Cancelled job did not reach CANCELLED state"); +const galleryBuild = await call(`/events/${eventId}/galleries/build`, { + method: "POST", + token: tenantA.token, + headers: { "idempotency-key": randomUUID() }, + expected: 202, +}); +if (galleryBuild.data.galleries_ready !== 1) + throw new Error("Gallery was not built"); +const delivery = await call(`/events/${eventId}/deliveries/send`, { + method: "POST", + token: tenantA.token, + headers: { "idempotency-key": randomUUID() }, + expected: 202, +}); +const galleryToken = delivery.data.development_gallery_urls[0].url + .split("/") + .pop(); const gallery = await call(`/public/gallery/${galleryToken}`); -if (gallery.data.photos.length !== 1) throw new Error("Private gallery did not contain exactly the matched media"); -const download = await call(`/public/gallery/${galleryToken}/download-url`, { method: "POST", body: JSON.stringify({ media_id: gallery.data.photos[0].id }) }); -if (!download.data.url) throw new Error("Authorized gallery download URL was not issued"); -const exportRequest = await call(`/public/gallery/${galleryToken}/exports`, { method: "POST", expected: 202 }); +if (gallery.data.photos.length !== 1) + throw new Error("Private gallery did not contain exactly the matched media"); +const download = await call(`/public/gallery/${galleryToken}/download-url`, { + method: "POST", + body: JSON.stringify({ media_id: gallery.data.photos[0].id }), +}); +if (!download.data.url) + throw new Error("Authorized gallery download URL was not issued"); +const exportRequest = await call( + `/public/gallery/${galleryToken}/download-all`, + { method: "POST", expected: 202 }, +); let exportStatus; for (let attempt = 0; attempt < 30; attempt += 1) { - exportStatus = await call(`/public/gallery/${galleryToken}/exports/${exportRequest.data.export_id}`); + exportStatus = await call( + `/public/gallery/${galleryToken}/exports/${exportRequest.data.export_id}`, + ); if (exportStatus.data.status === "READY") break; await new Promise((resolve) => setTimeout(resolve, 1000)); } -if (exportStatus.data.status !== "READY") throw new Error(`Gallery ZIP export did not finish: ${JSON.stringify(exportStatus.data)}`); -const exportDownload = await fetch(exportStatus.data.download_url.startsWith("http") ? exportStatus.data.download_url : `${origin}${exportStatus.data.download_url}`); +if (exportStatus.data.status !== "READY") + throw new Error( + `Gallery ZIP export did not finish: ${JSON.stringify(exportStatus.data)}`, + ); +const exportDownload = await fetch( + exportStatus.data.download_url.startsWith("http") + ? exportStatus.data.download_url + : `${origin}${exportStatus.data.download_url}`, +); const exportBytes = Buffer.from(await exportDownload.arrayBuffer()); -if (!exportDownload.ok || exportBytes.subarray(0, 2).toString() !== "PK") throw new Error("Gallery ZIP download was invalid"); +if (!exportDownload.ok || exportBytes.subarray(0, 2).toString() !== "PK") + throw new Error("Gallery ZIP download was invalid"); -await call(`/events/${eventId}`, { method: "DELETE", token: tenantA.token, expected: 202 }); -await call("/auth/logout", { method: "POST", token: tenantA.token, cookie: tenantA.refresh, expected: 204 }); +await call(`/events/${eventId}`, { + method: "DELETE", + token: tenantA.token, + expected: 202, +}); +await call("/auth/logout", { + method: "POST", + token: tenantA.token, + cookie: tenantA.refresh, + expected: 204, +}); await call("/auth/me", { token: tenantA.token, expected: 401 }); -await call(`/admin/organizations/${tenantA.organization.id}/schedule-deletion`, { method: "POST", token: adminToken, expected: 202 }); -await call(`/admin/organizations/${tenantB.organization.id}/schedule-deletion`, { method: "POST", token: adminToken, expected: 202 }); -await call("/auth/logout", { method: "POST", token: adminToken, cookie: adminRefresh, expected: 204 }); +await call( + `/admin/organizations/${tenantA.organization.id}/schedule-deletion`, + { method: "POST", token: adminToken, expected: 202 }, +); +await call( + `/admin/organizations/${tenantB.organization.id}/schedule-deletion`, + { method: "POST", token: adminToken, expected: 202 }, +); +await call("/auth/logout", { + method: "POST", + token: adminToken, + cookie: adminRefresh, + expected: 204, +}); -console.log(JSON.stringify({ - status: "passed", - refresh_rotation: true, - single_use_invitation: true, - tenant_isolation_statuses: [404, 404, 404], - import_preview: { valid: preview.data.valid_rows, invalid: preview.data.invalid_rows }, - idempotency: true, - embedding_dimension: enrollment.data.embedding_dimension, - processing: processing.data, - gallery_photos: gallery.data.photos.length, - gallery_export_bytes: exportBytes.length, - deletion_scheduled: true, - verification_tenants_scheduled_for_deletion: true, - logout_revocation: true, -}, null, 2)); +console.log( + JSON.stringify( + { + status: "passed", + refresh_rotation: true, + single_use_invitation: true, + tenant_isolation_statuses: [404, 404, 404], + import_preview: { + valid: preview.data.valid_rows, + invalid: preview.data.invalid_rows, + }, + idempotency: true, + embedding_dimension: enrollment.data.embedding_dimension, + exact_contracts: [ + "bulk-invites", + "direct-enrollment-upload", + "admin-job-detail", + "match-detail-filters", + "cancel-processing", + "download-all", + ], + processing: processing.data, + gallery_photos: gallery.data.photos.length, + gallery_export_bytes: exportBytes.length, + deletion_scheduled: true, + verification_tenants_scheduled_for_deletion: true, + logout_revocation: true, + }, + null, + 2, + ), +); diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 6cab466..611edaf 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -17,6 +17,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", "oxlint": "^1.75.0", + "prettier": "3.9.6", "vite": "^8.2.0" } }, @@ -1085,6 +1086,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", diff --git a/webapp/package.json b/webapp/package.json index a80734f..234ebe6 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", + "format": "prettier --write \"src/**/*.{js,jsx,css}\" \"*.{js,json,html}\" \"../tools/**/*.mjs\"", + "format:check": "prettier --check \"src/**/*.{js,jsx,css}\" \"*.{js,json,html}\" \"../tools/**/*.mjs\"", "lint": "oxlint", "preview": "vite preview" }, @@ -19,6 +21,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", "oxlint": "^1.75.0", + "prettier": "3.9.6", "vite": "^8.2.0" } } diff --git a/webapp/src/components/DashboardShell.css b/webapp/src/components/DashboardShell.css index a411274..c5a1c07 100644 --- a/webapp/src/components/DashboardShell.css +++ b/webapp/src/components/DashboardShell.css @@ -77,8 +77,11 @@ font-size: 13.5px; font-weight: 500; text-decoration: none; - transition: background 0.2s cubic-bezier(0.16, 1, 0.3, 1), color 0.2s cubic-bezier(0.16, 1, 0.3, 1), - border-color 0.2s cubic-bezier(0.16, 1, 0.3, 1), transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background 0.2s cubic-bezier(0.16, 1, 0.3, 1), + color 0.2s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.2s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .shell-nav-item svg { @@ -97,7 +100,11 @@ } .shell-nav-item.active { - background: linear-gradient(90deg, rgba(102, 94, 253, 0.26), rgba(102, 94, 253, 0.08)); + background: linear-gradient( + 90deg, + rgba(102, 94, 253, 0.26), + rgba(102, 94, 253, 0.08) + ); border-left-color: var(--indigo-500); color: #fff; font-weight: 600; @@ -116,7 +123,9 @@ color: rgba(232, 235, 247, 0.68); font-size: 13.5px; font-weight: 500; - transition: background 0.2s ease, color 0.2s ease; + transition: + background 0.2s ease, + color 0.2s ease; } .shell-logout:hover { @@ -147,9 +156,23 @@ color: var(--ink-soft); } -.shell-menu { display: none; } -.shell-alert { position: relative; padding: 8px; } -.shell-alert span { position: absolute; top: 6px; right: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); border: 1px solid white; } +.shell-menu { + display: none; +} +.shell-alert { + position: relative; + padding: 8px; +} +.shell-alert span { + position: absolute; + top: 6px; + right: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--danger); + border: 1px solid white; +} .shell-topbar h1 { font-size: 19px; @@ -221,20 +244,35 @@ width: min(290px, 86vw); grid-template-rows: auto 1fr auto; transform: translateX(-102%); - transition: transform .2s ease; + transition: transform 0.2s ease; } - .shell-sidebar.open { transform: translateX(0); box-shadow: 20px 0 50px rgba(8, 16, 48, .22); } + .shell-sidebar.open { + transform: translateX(0); + box-shadow: 20px 0 50px rgba(8, 16, 48, 0.22); + } .shell-nav { overflow-y: auto; } - .shell-menu { display: inline-grid; place-items: center; } - .shell-topbar { justify-content: flex-start; padding: 14px 18px; } - .shell-topbar-actions { margin-left: auto; } - .shell-search { display: none; } - .shell-content { padding: 20px 18px 32px; } + .shell-menu { + display: inline-grid; + place-items: center; + } + .shell-topbar { + justify-content: flex-start; + padding: 14px 18px; + } + .shell-topbar-actions { + margin-left: auto; + } + .shell-search { + display: none; + } + .shell-content { + padding: 20px 18px 32px; + } .shell-logout span { display: none; diff --git a/webapp/src/components/Dropzone.css b/webapp/src/components/Dropzone.css index 4a22ea1..5bc27f4 100644 --- a/webapp/src/components/Dropzone.css +++ b/webapp/src/components/Dropzone.css @@ -13,7 +13,9 @@ background: var(--surface-muted); color: var(--muted); text-align: center; - transition: border-color 0.15s ease, background 0.15s ease; + transition: + border-color 0.15s ease, + background 0.15s ease; } .dropzone-area:hover, diff --git a/webapp/src/components/Gauge.jsx b/webapp/src/components/Gauge.jsx index 982224d..ea3ac9a 100644 --- a/webapp/src/components/Gauge.jsx +++ b/webapp/src/components/Gauge.jsx @@ -2,7 +2,14 @@ import "./Gauge.css"; let gaugeId = 0; -export default function Gauge({ value, max, size = 108, strokeWidth = 10, label, sublabel }) { +export default function Gauge({ + value, + max, + size = 108, + strokeWidth = 10, + label, + sublabel, +}) { const id = nextGaugeId(); const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; diff --git a/webapp/src/components/Icon.jsx b/webapp/src/components/Icon.jsx index 70acecc..2a2c895 100644 --- a/webapp/src/components/Icon.jsx +++ b/webapp/src/components/Icon.jsx @@ -1,20 +1,27 @@ const PATHS = { dashboard: "M4 4h6v7H4V4Zm10 0h6v4h-6V4ZM4 14h6v6H4v-6Zm10-3h6v9h-6v-9Z", colleges: "M3 21h18M5 21V9l7-5 7 5v12M9 21v-6h6v6", - users: "M16 14a4 4 0 1 0-8 0M6 21a6 6 0 0 1 12 0M12 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z", + users: + "M16 14a4 4 0 1 0-8 0M6 21a6 6 0 0 1 12 0M12 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z", logs: "M8 5h13M8 12h13M8 19h13M3 5h.01M3 12h.01M3 19h.01", upload: "M12 16V4m0 0-4 4m4-4 4 4M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3", students: "M22 10 12 5 2 10l10 5 10-5Zm-4 2v5c0 1-2.7 3-6 3s-6-2-6-3v-5", - events: "M8 2v4M16 2v4M3.5 9h17M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z", + events: + "M8 2v4M16 2v4M3.5 9h17M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z", face: "M9 10h.01M15 10h.01M8 15c1 1.2 2.4 2 4 2s3-.8 4-2M4 7V5a2 2 0 0 1 2-2h2M4 17v2a2 2 0 0 0 2 2h2M20 7V5a2 2 0 0 0-2-2h-2M20 17v2a2 2 0 0 1-2 2h-2", logout: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9", search: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.35-4.35", chevron: "m6 9 6 6 6-6", - storage: "M4 7c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3Zm0 0v10c0 1.7 3.6 3 8 3s8-1.3 8-3V7M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3", - organization: "M3 21h18M6 21V7l6-4 6 4v14M9 10h.01M15 10h.01M9 14h.01M15 14h.01M10 21v-3h4v3", - processing: "M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83", - delivery: "M3 7l9 6 9-6M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z", - settings: "M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1V21h-4v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1-.4H3v-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1V3h4v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 .6 1 1.7 1.7 0 0 0 1 .4h.09v4H21a1.7 1.7 0 0 0-1.6.6Z", + storage: + "M4 7c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3Zm0 0v10c0 1.7 3.6 3 8 3s8-1.3 8-3V7M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3", + organization: + "M3 21h18M6 21V7l6-4 6 4v14M9 10h.01M15 10h.01M9 14h.01M15 14h.01M10 21v-3h4v3", + processing: + "M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83", + delivery: + "M3 7l9 6 9-6M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z", + settings: + "M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1V21h-4v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1-.4H3v-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1V3h4v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 .6 1 1.7 1.7 0 0 0 1 .4h.09v4H21a1.7 1.7 0 0 0-1.6.6Z", health: "M3 12h4l2-7 4 14 2-7h6", plus: "M12 5v14M5 12h14", close: "M6 6l12 12M18 6 6 18", @@ -26,7 +33,12 @@ const PATHS = { download: "M12 3v12m0 0 5-5m-5 5-5-5M5 21h14", }; -export default function Icon({ name, size = 18, strokeWidth = 1.8, className }) { +export default function Icon({ + name, + size = 18, + strokeWidth = 1.8, + className, +}) { const d = PATHS[name]; if (!d) return null; return ( diff --git a/webapp/src/components/LogsTable.jsx b/webapp/src/components/LogsTable.jsx index 47373f0..08ff9b6 100644 --- a/webapp/src/components/LogsTable.jsx +++ b/webapp/src/components/LogsTable.jsx @@ -26,7 +26,9 @@ export default function LogsTable({ logs, title, subtitle }) { {log.timestamp} {log.actor} - + {log.action} diff --git a/webapp/src/components/Modal.css b/webapp/src/components/Modal.css index ac10853..8235514 100644 --- a/webapp/src/components/Modal.css +++ b/webapp/src/components/Modal.css @@ -30,11 +30,25 @@ padding: 20px 22px; } -.modal-head { border-bottom: 1px solid var(--border); } -.modal-head h2 { font-size: 18px; } -.modal-head p { margin-top: 3px; color: var(--muted); font-size: 13px; } -.modal-body { padding: 22px; overflow-y: auto; } -.modal-footer { justify-content: flex-end; border-top: 1px solid var(--border); } +.modal-head { + border-bottom: 1px solid var(--border); +} +.modal-head h2 { + font-size: 18px; +} +.modal-head p { + margin-top: 3px; + color: var(--muted); + font-size: 13px; +} +.modal-body { + padding: 22px; + overflow-y: auto; +} +.modal-footer { + justify-content: flex-end; + border-top: 1px solid var(--border); +} .icon-button { width: 34px; @@ -46,4 +60,7 @@ background: var(--surface); color: var(--muted); } -.icon-button:hover { color: var(--ink); border-color: var(--border-input); } +.icon-button:hover { + color: var(--ink); + border-color: var(--border-input); +} diff --git a/webapp/src/components/Modal.jsx b/webapp/src/components/Modal.jsx index 4ec55ad..d592242 100644 --- a/webapp/src/components/Modal.jsx +++ b/webapp/src/components/Modal.jsx @@ -2,7 +2,14 @@ import { useEffect } from "react"; import Icon from "./Icon"; import "./Modal.css"; -export default function Modal({ open, title, description, onClose, children, footer }) { +export default function Modal({ + open, + title, + description, + onClose, + children, + footer, +}) { useEffect(() => { if (!open) return undefined; function onKeyDown(event) { @@ -15,14 +22,27 @@ export default function Modal({ open, title, description, onClose, children, foo if (!open) return null; return ( -
event.target === event.currentTarget && onClose()}> -
+
event.target === event.currentTarget && onClose()} + > +
{description ?

{description}

: null}
-
diff --git a/webapp/src/components/PageState.jsx b/webapp/src/components/PageState.jsx index 80fb123..9768df7 100644 --- a/webapp/src/components/PageState.jsx +++ b/webapp/src/components/PageState.jsx @@ -1,6 +1,24 @@ export default function PageState({ loading, error, empty, children }) { - if (loading) return
Loading live FDX data…
; - if (error) return
Unable to load this workspace

{error}

; - if (empty) return
No records yet

Create the first record to begin this workflow.

; + if (loading) + return ( +
+ + Loading live FDX data… +
+ ); + if (error) + return ( +
+ Unable to load this workspace +

{error}

+
+ ); + if (empty) + return ( +
+ No records yet +

Create the first record to begin this workflow.

+
+ ); return children; } diff --git a/webapp/src/context/AuthContext.jsx b/webapp/src/context/AuthContext.jsx index c3413aa..2583bf0 100644 --- a/webapp/src/context/AuthContext.jsx +++ b/webapp/src/context/AuthContext.jsx @@ -1,6 +1,11 @@ /* oxlint-disable react/only-export-components -- Provider and hook form one public context API. */ import { createContext, useContext, useEffect, useMemo, useState } from "react"; -import { initializeSession, loginRequest, logoutRequest, storeSession } from "../lib/api"; +import { + initializeSession, + loginRequest, + logoutRequest, + storeSession, +} from "../lib/api"; const AuthContext = createContext(null); @@ -12,31 +17,36 @@ export function AuthProvider({ children }) { const refreshed = (event) => setSession(event.detail); window.addEventListener("fdx:session-cleared", clear); window.addEventListener("fdx:session-refreshed", refreshed); - initializeSession().then(setSession).finally(() => setLoading(false)); + initializeSession() + .then(setSession) + .finally(() => setLoading(false)); return () => { window.removeEventListener("fdx:session-cleared", clear); window.removeEventListener("fdx:session-refreshed", refreshed); }; }, []); - const value = useMemo(() => ({ - user: session?.user ?? null, - isAuthenticated: Boolean(session?.user && session?.token), - loading, - async login(email, password) { - const nextSession = await loginRequest(email, password); - storeSession(nextSession); - setSession(nextSession); - return nextSession; - }, - setAuthenticatedSession(nextSession) { - const normalized = storeSession(nextSession); - setSession(normalized); - }, - async logout() { - await logoutRequest(); - setSession(null); - }, - }), [loading, session]); + const value = useMemo( + () => ({ + user: session?.user ?? null, + isAuthenticated: Boolean(session?.user && session?.token), + loading, + async login(email, password) { + const nextSession = await loginRequest(email, password); + storeSession(nextSession); + setSession(nextSession); + return nextSession; + }, + setAuthenticatedSession(nextSession) { + const normalized = storeSession(nextSession); + setSession(normalized); + }, + async logout() { + await logoutRequest(); + setSession(null); + }, + }), + [loading, session], + ); return {children}; } diff --git a/webapp/src/context/PlatformContext.jsx b/webapp/src/context/PlatformContext.jsx index c9801a3..5604861 100644 --- a/webapp/src/context/PlatformContext.jsx +++ b/webapp/src/context/PlatformContext.jsx @@ -7,15 +7,60 @@ import { useMemo, useState, } from "react"; -import { api, directUpload } from "../lib/api"; +import { api, directUpload, directUploadPart } from "../lib/api"; import { useAuth } from "./AuthContext"; const PlatformContext = createContext(null); const uuid = () => crypto.randomUUID(); async function checksum(file) { - const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer()); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + const digest = await crypto.subtle.digest( + "SHA-256", + await file.arrayBuffer(), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +async function runBounded(tasks, concurrency = 6) { + let next = 0; + async function worker() { + while (next < tasks.length) { + const index = next; + next += 1; + await tasks[index](); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, tasks.length) }, worker), + ); +} + +async function uploadTarget(target, file) { + if (!target.multipart) { + await directUpload(target.upload_url, file, target.headers); + return; + } + const completed = []; + await runBounded( + target.parts.map((part) => async () => { + const start = (part.part_number - 1) * target.part_size; + const bytes = file.slice( + start, + Math.min(file.size, start + target.part_size), + ); + const etag = await directUploadPart(part.upload_url, bytes); + completed.push({ part_number: part.part_number, etag }); + }), + ); + await api(target.complete_url, { + method: "POST", + body: JSON.stringify({ + upload_id: target.multipart_upload_id, + parts: completed, + }), + }); } const initialState = { organizations: [], @@ -170,17 +215,23 @@ export function PlatformProvider({ children }) { validateParticipantImport: async (eventId, file) => { const body = new FormData(); body.append("file", file); - const response = await api(`/v2/events/${eventId}/participant-imports`, { - method: "POST", - body, - }); + const response = await api( + `/v2/events/${eventId}/participant-imports`, + { + method: "POST", + body, + }, + ); return response.data; }, confirmParticipantImport: async (eventId, importId) => { - const response = await api(`/v2/events/${eventId}/participant-imports/${importId}/confirm`, { - method: "POST", - headers: { "Idempotency-Key": uuid() }, - }); + const response = await api( + `/v2/events/${eventId}/participant-imports/${importId}/confirm`, + { + method: "POST", + headers: { "Idempotency-Key": uuid() }, + }, + ); await refresh(); return response.data; }, @@ -191,28 +242,52 @@ export function PlatformProvider({ children }) { files.forEach((file) => body.append("files", file)); return mutate("/organization/photos", { method: "POST", body }); } - const manifest = await Promise.all(files.map(async (file) => ({ - filename: file.webkitRelativePath || file.name, - content_type: file.type, - size_bytes: file.size, - sha256: await checksum(file), - }))); + const manifest = await Promise.all( + files.map(async (file) => ({ + filename: file.webkitRelativePath || file.name, + content_type: file.type, + size_bytes: file.size, + sha256: await checksum(file), + })), + ); const reservation = await api(`/v2/events/${eventId}/upload-batches`, { method: "POST", - body: JSON.stringify({ expected_files: files.length, reserved_bytes: files.reduce((sum, file) => sum + file.size, 0) }), + body: JSON.stringify({ + expected_files: files.length, + reserved_bytes: files.reduce((sum, file) => sum + file.size, 0), + }), }); const batchId = reservation.data.id; - const presigned = await api(`/v2/events/${eventId}/upload-batches/${batchId}/presign`, { - method: "POST", - body: JSON.stringify({ files: manifest }), - }); - await Promise.all(presigned.data.files.map((target, index) => directUpload(target.upload_url, files[index], target.headers))); - const completed = await api(`/v2/events/${eventId}/upload-batches/${batchId}/complete`, { - method: "POST", - headers: { "Idempotency-Key": uuid() }, - }); + for (let offset = 0; offset < files.length; offset += 500) { + const chunkFiles = files.slice(offset, offset + 500); + const presigned = await api( + `/v2/events/${eventId}/upload-batches/${batchId}/presign`, + { + method: "POST", + body: JSON.stringify({ + files: manifest.slice(offset, offset + 500), + }), + }, + ); + await runBounded( + presigned.data.files.map( + (target, index) => () => uploadTarget(target, chunkFiles[index]), + ), + ); + } + const completed = await api( + `/v2/events/${eventId}/upload-batches/${batchId}/complete`, + { + method: "POST", + headers: { "Idempotency-Key": uuid() }, + }, + ); await refresh(); - return { uploaded: completed.data.jobs, jobsPublished: completed.data.jobs.length, skipped: [] }; + return { + uploaded: completed.data.jobs, + jobsPublished: completed.data.jobs.length, + skipped: [], + }; }, uploadPhotosLegacy: (eventId, files) => { const body = new FormData(); diff --git a/webapp/src/index.css b/webapp/src/index.css index 1e79d3a..930a7b4 100644 --- a/webapp/src/index.css +++ b/webapp/src/index.css @@ -23,13 +23,37 @@ --cream-deep: #9b6829; --gradient-primary: linear-gradient(120deg, #533afd 0%, #665efd 100%); - --gradient-soft: linear-gradient(135deg, rgba(83, 58, 253, 0.1), rgba(102, 94, 253, 0.07)); + --gradient-soft: linear-gradient( + 135deg, + rgba(83, 58, 253, 0.1), + rgba(102, 94, 253, 0.07) + ); --gradient-mesh: - radial-gradient(60% 55% at 8% 8%, rgba(245, 233, 212, 0.9), transparent 60%), - radial-gradient(50% 50% at 32% 0%, rgba(155, 104, 41, 0.28), transparent 65%), - radial-gradient(55% 60% at 60% 10%, rgba(102, 94, 253, 0.5), transparent 65%), - radial-gradient(45% 55% at 85% 5%, rgba(234, 34, 97, 0.35), transparent 65%), - radial-gradient(40% 45% at 100% 25%, rgba(249, 107, 238, 0.3), transparent 65%), + radial-gradient( + 60% 55% at 8% 8%, + rgba(245, 233, 212, 0.9), + transparent 60% + ), + radial-gradient( + 50% 50% at 32% 0%, + rgba(155, 104, 41, 0.28), + transparent 65% + ), + radial-gradient( + 55% 60% at 60% 10%, + rgba(102, 94, 253, 0.5), + transparent 65% + ), + radial-gradient( + 45% 55% at 85% 5%, + rgba(234, 34, 97, 0.35), + transparent 65% + ), + radial-gradient( + 40% 45% at 100% 25%, + rgba(249, 107, 238, 0.3), + transparent 65% + ), var(--bg); --success: #1c9a6c; @@ -47,8 +71,10 @@ --radius-pill: 9999px; --shadow-sm: 0 1px 3px rgba(0, 55, 112, 0.08); - --shadow-md: 0 8px 24px rgba(0, 55, 112, 0.08), 0 2px 6px rgba(0, 55, 112, 0.04); - --shadow-lg: 0 16px 40px rgba(0, 55, 112, 0.12), 0 4px 10px rgba(0, 55, 112, 0.06); + --shadow-md: + 0 8px 24px rgba(0, 55, 112, 0.08), 0 2px 6px rgba(0, 55, 112, 0.04); + --shadow-lg: + 0 16px 40px rgba(0, 55, 112, 0.12), 0 4px 10px rgba(0, 55, 112, 0.06); --space-xxs: 2px; --space-xs: 4px; @@ -73,8 +99,15 @@ body { margin: 0; background: var(--bg); color: var(--ink); - font: 400 15px/1.55 "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-feature-settings: "ss01" 1, "cv05" 1; + font: + 400 15px/1.55 "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-feature-settings: + "ss01" 1, + "cv05" 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -152,7 +185,11 @@ a { color: var(--ink); font-weight: 400; font-size: 14px; - transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; + transition: + border-color 0.15s ease, + background 0.15s ease, + transform 0.15s ease, + box-shadow 0.15s ease; } .btn:hover { @@ -464,115 +501,1180 @@ tbody tr:hover { /* ---------- workflow application ---------- */ -.eyebrow { color: var(--violet-600) !important; font-size: 10px !important; font-weight: 700; letter-spacing: .12em !important; text-transform: uppercase; } -.live-chip, .policy-chips span, .architecture-note { display: inline-flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); color: var(--muted); font-size: 12px; } -.live-chip > span { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 4px var(--success-bg); } -.policy-chips { display: flex; gap: 8px; flex-wrap: wrap; } -.stat-grid-wide { grid-template-columns: repeat(4, minmax(180px, 1fr)); } -.text-link { color: var(--violet-600); text-decoration: none; font-size: 13px; font-weight: 600; } -.success-text { color: var(--success); font-size: 13px; } -.usage-list, .service-list, .event-list { display: grid; gap: 2px; } -.usage-row { display: flex; align-items: center; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--border); } -.usage-row:last-child { border-bottom: 0; } -.org-avatar, .person-avatar { width: 40px; height: 40px; display: inline-grid; place-items: center; flex: none; border-radius: 12px; background: linear-gradient(145deg,#edeaff,#ddd9ff); color: var(--violet-600); font-size: 11px; font-weight: 700; } -.org-avatar.small, .person-avatar { width: 34px; height: 34px; border-radius: 10px; } -.person-avatar { border-radius: 50%; background: var(--surface-cream); color: var(--cream-deep); } -.usage-main { flex: 1; min-width: 0; } -.usage-main > div:first-child, .usage-value { display: grid; } -.usage-main strong, .usage-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.usage-main strong, .usage-value strong, .service-item strong, .event-list-main strong { font-size: 13px; } -.usage-main span, .usage-value span, .service-item p, .activity-detail, .event-list-main span { color: var(--muted); font-size: 11px; } -.usage-main .progress-track { margin-top: 8px; } -.usage-value { min-width: 78px; text-align: right; } -.progress-track { height: 5px; overflow: hidden; border-radius: 99px; background: #edf0f5; } -.progress-track > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg,var(--violet-500),var(--magenta)); } -.service-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; } -.service-item { display: flex; align-items: center; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-md); } -.service-status { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; } -.service-status.healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-bg); } -.service-status.degraded { background: #e2a229; box-shadow: 0 0 0 4px var(--warning-bg); } - -.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } -.toolbar select, .toolbar-search { height: 38px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); } -.toolbar select { padding: 0 12px; } -.toolbar-search { min-width: 260px; display: flex; align-items: center; gap: 8px; padding: 0 11px; } -.toolbar-search input { width: 100%; border: 0; outline: 0; background: transparent; } -.result-count { margin-left: auto; color: var(--muted); font-size: 12px; } -.admin-split { display: grid; grid-template-columns: minmax(0,1.8fr) minmax(300px,.75fr); gap: 16px; align-items: start; } -.organizations-table tr { cursor: pointer; } -.table-identity, .photo-cell { display: flex; align-items: center; gap: 10px; } -.table-identity > div { display: grid; } -.table-identity strong { font-size: 13px; } -.table-identity span { font-size: 11px; color: var(--muted); } -.table-identity > span { color: var(--violet-600); } -.mini-usage, .inline-progress { display: flex; align-items: center; gap: 8px; font-size: 12px; } -.mini-usage > span, .inline-progress > span { width: 60px; height: 4px; overflow: hidden; border-radius: 99px; background: #edf0f5; } -.mini-usage i, .inline-progress i { display: block; height: 100%; border-radius: inherit; background: var(--violet-500); } -.organization-detail { position: sticky; top: 20px; } -.contact-block { display: grid; padding: 14px; border-radius: var(--radius-md); background: var(--surface-muted); } -.contact-block strong { font-size: 13px; } -.contact-block span { font-size: 12px; color: var(--muted); } -.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } -.form-grid.single { grid-template-columns: 1fr; } -.form-grid .full { grid-column: 1/-1; } -.field textarea { resize: vertical; border: 1px solid var(--border-input); border-radius: var(--radius-sm); padding: 9px 12px; } -.field textarea:focus { outline: 0; border-color: var(--violet-500); box-shadow: 0 0 0 3px rgba(83,58,253,.15); } -.notice, .notice-inline { display: flex; align-items: center; gap: 9px; border-radius: var(--radius-md); font-size: 13px; } -.notice { padding: 11px 14px; } -.notice.success, .notice-inline { color: var(--success); background: var(--success-bg); } -.notice .btn { margin-left: auto; } -.notice-inline { padding: 7px 10px; } -.permission-note, .validation-summary { display: flex; gap: 10px; padding: 14px; border-radius: var(--radius-md); background: var(--surface-muted); } -.permission-note { display: grid; } -.permission-note p, .validation-summary p { color: var(--muted); font-size: 12px; } - -.service-line, .job-row { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); } -.service-line:last-child, .job-row:last-child { border: 0; } -.service-line > div { display: flex; align-items: center; gap: 10px; } -.service-line > span, .job-row span { color: var(--muted); font-size: 12px; } -.job-row > div { display: grid; } -.job-row code { padding: 4px 7px; background: var(--surface-muted); border-radius: 4px; font-size: 11px; } -.queue-row { display: grid; gap: 8px; padding: 9px 0; } -.queue-row > div:first-child { display: flex; justify-content: space-between; font-size: 13px; } -.queue-row span { color: var(--muted); } -.architecture-flow { display: grid; grid-template-columns: repeat(8, minmax(115px,1fr)); gap: 14px; align-items: stretch; } -.architecture-start, .architecture-layer { position: relative; display: flex; align-items: center; gap: 9px; min-height: 82px; padding: 13px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-muted); } -.architecture-start { justify-content: center; background: var(--brand-dark); color: white; font-weight: 700; letter-spacing: .1em; } -.architecture-layer:not(:last-child)::after, .architecture-start::after { content:"→"; position:absolute; right:-13px; top:50%; z-index:2; transform:translate(50%,-50%); color:var(--muted); } -.architecture-layer strong { font-size: 12px; } -.architecture-layer p { margin-top: 2px; color: var(--muted); font-size: 10px; } -.architecture-layer.purple svg { color:var(--violet-500) }.architecture-layer.blue svg{color:#1681d4}.architecture-layer.teal svg{color:#169f9a}.architecture-layer.orange svg{color:#dd7b22}.architecture-layer.pink svg{color:var(--ruby)}.architecture-layer.green svg{color:var(--success)} - -.event-list-row { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); } -.event-list-row:last-child { border:0; } -.date-tile { width: 42px; height: 46px; display:grid; place-items:center; align-content:center; border-radius:10px; background:var(--gradient-soft); color:var(--violet-600); line-height:1.05; } -.date-tile strong { font-size:17px; }.date-tile span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;} -.event-list-main { display: grid; gap: 2px; } -.event-list-main .progress-track { margin-top:5px; width:min(320px,100%); } -.retention-callout { display:grid; grid-template-columns:1fr 1fr; gap:10px; } -.retention-callout div { display:grid; padding:13px; border-radius:var(--radius-md); background:var(--surface-muted); } -.retention-callout span{font-size:11px;color:var(--muted)}.retention-callout strong{font-size:14px} -.funnel-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; } -.funnel-grid > div { position:relative; padding:15px; border-radius:var(--radius-md); background:var(--surface-muted); } -.funnel-grid > div:not(:last-child)::after { content:"→"; position:absolute; right:-8px; top:50%; color:var(--muted); } -.funnel-grid strong { display:block; margin-top:12px; font-size:20px; }.funnel-grid p{font-size:11px;color:var(--muted)}.funnel-index{font-size:9px;color:var(--violet-500);} -.workflow-strip { display:grid; grid-template-columns:repeat(5,1fr); padding:13px 16px; border:1px solid var(--border); border-radius:var(--radius-lg); background:var(--surface); } -.workflow-strip > div { display:flex; align-items:center; gap:8px; }.workflow-strip span{display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--gradient-soft);color:var(--violet-600);font-size:10px;font-weight:700}.workflow-strip p{font-size:11px}.workflow-strip svg{margin-left:auto;color:var(--muted)} -.event-card.modern { gap:15px; }.event-card.modern .event-card-head{align-items:flex-start}.event-card.modern h3{font-size:16px}.event-progress{display:grid;gap:6px}.event-progress>div{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}.event-card footer{display:flex;justify-content:space-between;align-items:center;padding-top:8px;border-top:1px solid var(--border);font-size:10px;color:var(--muted)} -.upload-layout { display:grid;grid-template-columns:1.5fr .7fr;gap:16px; }.upload-guidance>div{position:relative;padding-left:42px}.upload-guidance h3{font-size:13px}.upload-guidance p{color:var(--muted);font-size:11px}.step-number{position:absolute;left:0;top:0;color:var(--violet-500);font-size:10px;font-weight:700} -.segmented { display:inline-flex;padding:3px;border-radius:var(--radius-md);background:#e9edf4; }.segmented button{padding:6px 11px;border:0;border-radius:6px;background:transparent;font-size:12px;color:var(--muted)}.segmented button.active{background:var(--surface);color:var(--ink);box-shadow:var(--shadow-sm)} -.photo-placeholder { width:38px;height:32px;display:grid;place-items:center;border-radius:6px;background:linear-gradient(135deg,#d7e5ff,#f1dff4);color:var(--violet-500); }.confidence-meter{display:flex;align-items:center;gap:8px}.confidence-meter>span{width:70px;height:5px;border-radius:99px;background:#edf0f5;overflow:hidden}.confidence-meter i{display:block;height:100%;background:var(--violet-500)}.confidence-meter strong{font-size:12px}.row-actions{display:flex;gap:4px} -.pipeline { position:relative;display:grid;grid-template-columns:repeat(5,1fr);gap:12px;padding:14px 0; }.pipeline-line{position:absolute;left:8%;right:8%;top:34px;height:2px;background:var(--border)}.pipeline-stage{z-index:1;display:grid;justify-items:center;text-align:center;gap:3px}.pipeline-stage>div{width:42px;height:42px;display:grid;place-items:center;border:5px solid var(--surface);border-radius:50%;background:#eef0f5;color:var(--muted);font-size:11px}.pipeline-stage>div.complete{background:var(--success);color:white}.pipeline-stage>div.active{background:var(--violet-500);color:white;box-shadow:0 0 0 4px #e9e7ff}.pipeline-stage strong{font-size:12px}.pipeline-stage span,.pipeline-stage small{font-size:10px;color:var(--muted)} -.confidence-list{display:grid;gap:9px}.confidence-list>div{display:grid;grid-template-columns:1fr auto;gap:2px;padding:12px;border-left:3px solid;border-radius:0 var(--radius-md) var(--radius-md) 0;background:var(--surface-muted)}.confidence-list .high{border-color:var(--success)}.confidence-list .review{border-color:#e2a229}.confidence-list .low{border-color:var(--danger)}.confidence-list strong,.confidence-list span{font-size:12px}.confidence-list p{grid-column:1/-1;color:var(--muted);font-size:11px} -.delivery-preview { display:grid;grid-template-columns:1fr 1fr;overflow:hidden;background:linear-gradient(130deg,#141b42,#28277b);color:white;border:0; }.delivery-copy{padding:32px}.delivery-copy h3{margin-top:10px;font-size:22px}.delivery-copy>p{margin:8px 0 18px;color:#c6c9df;font-size:13px}.gallery-mosaic{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;padding:20px;transform:rotate(-3deg) scale(1.04)}.gallery-mosaic div{min-height:80px;display:grid;place-items:center;border-radius:8px;background:linear-gradient(145deg,#6e67e9,#ee77bd);color:rgba(255,255,255,.7)} -.settings-layout{display:grid;grid-template-columns:220px 1fr;gap:16px;align-items:start}.settings-nav{display:grid;padding:7px;position:sticky;top:20px}.settings-nav button{text-align:left;padding:10px;border:0;border-radius:7px;background:transparent;color:var(--muted);font-size:12px}.settings-nav button.active{background:var(--gradient-soft);color:var(--violet-600);font-weight:600}.settings-content{display:grid;gap:16px}.settings-save{justify-self:start}.locked-policy{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.locked-policy>div{display:grid;grid-template-columns:auto 1fr;gap:2px 9px;padding:14px;border-radius:var(--radius-md);background:var(--surface-muted)}.locked-policy svg{grid-row:1/3;color:var(--violet-500)}.locked-policy span{font-size:11px;color:var(--muted)}.locked-policy strong{font-size:13px}.toggle-setting{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}.toggle-setting:last-child{border:0}.toggle-setting strong{font-size:13px}.toggle-setting p{font-size:11px;color:var(--muted)} - -@media (max-width: 1200px) { .stat-grid-wide{grid-template-columns:repeat(2,1fr)} .architecture-flow{grid-template-columns:repeat(4,1fr)} .architecture-layer:nth-child(4)::after{display:none} .admin-split{grid-template-columns:1fr}.organization-detail{position:static}.funnel-grid{grid-template-columns:repeat(3,1fr)} } -@media (max-width: 760px) { .stat-grid,.stat-grid-wide{grid-template-columns:1fr 1fr}.service-grid{grid-template-columns:1fr}.architecture-flow{grid-template-columns:1fr}.architecture-start::after,.architecture-layer:not(:last-child)::after{content:"↓";right:50%;top:auto;bottom:-14px;transform:translate(50%,50%)}.form-grid{grid-template-columns:1fr}.form-grid .full{grid-column:auto}.workflow-strip{grid-template-columns:1fr;gap:7px}.workflow-strip svg{transform:rotate(90deg)}.upload-layout,.delivery-preview,.settings-layout{grid-template-columns:1fr}.pipeline{grid-template-columns:1fr;padding-left:0}.pipeline-line{left:50%;right:auto;top:8%;bottom:8%;width:2px;height:auto}.pipeline-stage{background:var(--surface);padding:6px}.locked-policy{grid-template-columns:1fr}.funnel-grid{grid-template-columns:1fr 1fr}.funnel-grid>div:not(:last-child)::after{display:none}.result-count{width:100%;margin:0}.toolbar-search{min-width:100%} } -@media (max-width: 480px) { .stat-grid,.stat-grid-wide{grid-template-columns:1fr}.funnel-grid{grid-template-columns:1fr}.retention-callout{grid-template-columns:1fr}.modal-card{max-height:calc(100vh - 18px)}.modal-backdrop{padding:9px}.section{padding:17px}.page{gap:17px} } - -.page-state { min-height: 180px; padding: 32px; display:grid; place-items:center; align-content:center; gap:8px; text-align:center; color:var(--muted); } -.page-state.error { color:var(--danger); background:var(--danger-bg); }.page-state p{font-size:13px}.state-spinner{width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--violet-500);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} -.public-shell { min-height:100vh;display:grid;place-items:center;padding:24px;background:var(--gradient-mesh); }.public-card{width:min(620px,100%);padding:28px;display:grid;gap:18px}.public-brand{display:flex;align-items:center;gap:14px}.public-card h1{font-size:23px}.event-summary{display:grid;padding:14px;border-radius:var(--radius-md);background:var(--gradient-soft)}.event-summary span{font-size:12px;color:var(--muted)}.camera-frame{aspect-ratio:4/3;display:grid;place-items:center;overflow:hidden;border-radius:var(--radius-lg);background:#10152f}.camera-frame video,.camera-frame img{width:100%;height:100%;object-fit:cover}.camera-actions{display:flex;gap:8px;flex-wrap:wrap}.consent-row{display:flex;align-items:flex-start;gap:10px;padding:13px;border-radius:var(--radius-md);background:var(--surface-muted);font-size:12px;color:var(--muted)}.consent-row input{margin-top:3px}.success-view{text-align:center;justify-items:center}.success-mark{width:60px;height:60px;display:grid;place-items:center;border-radius:50%;background:var(--success-bg);color:var(--success)} -.gallery-page{min-height:100vh;padding:28px clamp(18px,5vw,72px);background:var(--bg)}.gallery-header{display:flex;align-items:center;gap:16px}.gallery-header h1{font-size:26px}.gallery-header p{color:var(--muted);font-size:13px}.gallery-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:28px 0 14px;color:var(--muted);font-size:12px}.gallery-toolbar>div{display:flex;align-items:center;gap:12px}.gallery-actions .btn{color:inherit;text-decoration:none}.gallery-actions .primary{color:#fff}.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}.photo-gallery figure{position:relative;margin:0;overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--surface);box-shadow:var(--shadow-sm)}.photo-gallery figure.selected{border-color:var(--violet-500);box-shadow:0 0 0 2px rgba(108,92,231,.18)}.photo-select{position:absolute;z-index:2;top:10px;right:10px;width:30px;height:30px;display:grid;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;background:rgba(20,27,66,.72);color:#fff;cursor:pointer}.photo-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}.photo-gallery figcaption{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;font-size:12px}.photo-gallery a,.photo-gallery .link-button{display:flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--violet-600);font:inherit;text-decoration:none;cursor:pointer} -.btn.danger{background:var(--danger);border-color:var(--danger);color:#fff}.table-error{display:block;max-width:340px;margin-top:3px;color:var(--danger);font-size:10px;white-space:normal}.settings-nav button:disabled{opacity:.45;cursor:not-allowed} +.eyebrow { + color: var(--violet-600) !important; + font-size: 10px !important; + font-weight: 700; + letter-spacing: 0.12em !important; + text-transform: uppercase; +} +.live-chip, +.policy-chips span, +.architecture-note { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--surface); + color: var(--muted); + font-size: 12px; +} +.live-chip > span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 4px var(--success-bg); +} +.policy-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.stat-grid-wide { + grid-template-columns: repeat(4, minmax(180px, 1fr)); +} +.text-link { + color: var(--violet-600); + text-decoration: none; + font-size: 13px; + font-weight: 600; +} +.success-text { + color: var(--success); + font-size: 13px; +} +.usage-list, +.service-list, +.event-list { + display: grid; + gap: 2px; +} +.usage-row { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid var(--border); +} +.usage-row:last-child { + border-bottom: 0; +} +.org-avatar, +.person-avatar { + width: 40px; + height: 40px; + display: inline-grid; + place-items: center; + flex: none; + border-radius: 12px; + background: linear-gradient(145deg, #edeaff, #ddd9ff); + color: var(--violet-600); + font-size: 11px; + font-weight: 700; +} +.org-avatar.small, +.person-avatar { + width: 34px; + height: 34px; + border-radius: 10px; +} +.person-avatar { + border-radius: 50%; + background: var(--surface-cream); + color: var(--cream-deep); +} +.usage-main { + flex: 1; + min-width: 0; +} +.usage-main > div:first-child, +.usage-value { + display: grid; +} +.usage-main strong, +.usage-main span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.usage-main strong, +.usage-value strong, +.service-item strong, +.event-list-main strong { + font-size: 13px; +} +.usage-main span, +.usage-value span, +.service-item p, +.activity-detail, +.event-list-main span { + color: var(--muted); + font-size: 11px; +} +.usage-main .progress-track { + margin-top: 8px; +} +.usage-value { + min-width: 78px; + text-align: right; +} +.progress-track { + height: 5px; + overflow: hidden; + border-radius: 99px; + background: #edf0f5; +} +.progress-track > span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--violet-500), var(--magenta)); +} +.service-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} +.service-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); +} +.service-status { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + flex: none; +} +.service-status.healthy { + background: var(--success); + box-shadow: 0 0 0 4px var(--success-bg); +} +.service-status.degraded { + background: #e2a229; + box-shadow: 0 0 0 4px var(--warning-bg); +} + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.toolbar select, +.toolbar-search { + height: 38px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} +.toolbar select { + padding: 0 12px; +} +.toolbar-search { + min-width: 260px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 11px; +} +.toolbar-search input { + width: 100%; + border: 0; + outline: 0; + background: transparent; +} +.result-count { + margin-left: auto; + color: var(--muted); + font-size: 12px; +} +.admin-split { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(300px, 0.75fr); + gap: 16px; + align-items: start; +} +.organizations-table tr { + cursor: pointer; +} +.table-identity, +.photo-cell { + display: flex; + align-items: center; + gap: 10px; +} +.table-identity > div { + display: grid; +} +.table-identity strong { + font-size: 13px; +} +.table-identity span { + font-size: 11px; + color: var(--muted); +} +.table-identity > span { + color: var(--violet-600); +} +.mini-usage, +.inline-progress { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} +.mini-usage > span, +.inline-progress > span { + width: 60px; + height: 4px; + overflow: hidden; + border-radius: 99px; + background: #edf0f5; +} +.mini-usage i, +.inline-progress i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--violet-500); +} +.organization-detail { + position: sticky; + top: 20px; +} +.contact-block { + display: grid; + padding: 14px; + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.contact-block strong { + font-size: 13px; +} +.contact-block span { + font-size: 12px; + color: var(--muted); +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} +.form-grid.single { + grid-template-columns: 1fr; +} +.form-grid .full { + grid-column: 1/-1; +} +.field textarea { + resize: vertical; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + padding: 9px 12px; +} +.field textarea:focus { + outline: 0; + border-color: var(--violet-500); + box-shadow: 0 0 0 3px rgba(83, 58, 253, 0.15); +} +.notice, +.notice-inline { + display: flex; + align-items: center; + gap: 9px; + border-radius: var(--radius-md); + font-size: 13px; +} +.notice { + padding: 11px 14px; +} +.notice.success, +.notice-inline { + color: var(--success); + background: var(--success-bg); +} +.notice .btn { + margin-left: auto; +} +.notice-inline { + padding: 7px 10px; +} +.permission-note, +.validation-summary { + display: flex; + gap: 10px; + padding: 14px; + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.permission-note { + display: grid; +} +.permission-note p, +.validation-summary p { + color: var(--muted); + font-size: 12px; +} + +.service-line, +.job-row { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} +.service-line:last-child, +.job-row:last-child { + border: 0; +} +.service-line > div { + display: flex; + align-items: center; + gap: 10px; +} +.service-line > span, +.job-row span { + color: var(--muted); + font-size: 12px; +} +.job-row > div { + display: grid; +} +.job-row code { + padding: 4px 7px; + background: var(--surface-muted); + border-radius: 4px; + font-size: 11px; +} +.queue-row { + display: grid; + gap: 8px; + padding: 9px 0; +} +.queue-row > div:first-child { + display: flex; + justify-content: space-between; + font-size: 13px; +} +.queue-row span { + color: var(--muted); +} +.architecture-flow { + display: grid; + grid-template-columns: repeat(8, minmax(115px, 1fr)); + gap: 14px; + align-items: stretch; +} +.architecture-start, +.architecture-layer { + position: relative; + display: flex; + align-items: center; + gap: 9px; + min-height: 82px; + padding: 13px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.architecture-start { + justify-content: center; + background: var(--brand-dark); + color: white; + font-weight: 700; + letter-spacing: 0.1em; +} +.architecture-layer:not(:last-child)::after, +.architecture-start::after { + content: "→"; + position: absolute; + right: -13px; + top: 50%; + z-index: 2; + transform: translate(50%, -50%); + color: var(--muted); +} +.architecture-layer strong { + font-size: 12px; +} +.architecture-layer p { + margin-top: 2px; + color: var(--muted); + font-size: 10px; +} +.architecture-layer.purple svg { + color: var(--violet-500); +} +.architecture-layer.blue svg { + color: #1681d4; +} +.architecture-layer.teal svg { + color: #169f9a; +} +.architecture-layer.orange svg { + color: #dd7b22; +} +.architecture-layer.pink svg { + color: var(--ruby); +} +.architecture-layer.green svg { + color: var(--success); +} + +.event-list-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} +.event-list-row:last-child { + border: 0; +} +.date-tile { + width: 42px; + height: 46px; + display: grid; + place-items: center; + align-content: center; + border-radius: 10px; + background: var(--gradient-soft); + color: var(--violet-600); + line-height: 1.05; +} +.date-tile strong { + font-size: 17px; +} +.date-tile span { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.event-list-main { + display: grid; + gap: 2px; +} +.event-list-main .progress-track { + margin-top: 5px; + width: min(320px, 100%); +} +.retention-callout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.retention-callout div { + display: grid; + padding: 13px; + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.retention-callout span { + font-size: 11px; + color: var(--muted); +} +.retention-callout strong { + font-size: 14px; +} +.funnel-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; +} +.funnel-grid > div { + position: relative; + padding: 15px; + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.funnel-grid > div:not(:last-child)::after { + content: "→"; + position: absolute; + right: -8px; + top: 50%; + color: var(--muted); +} +.funnel-grid strong { + display: block; + margin-top: 12px; + font-size: 20px; +} +.funnel-grid p { + font-size: 11px; + color: var(--muted); +} +.funnel-index { + font-size: 9px; + color: var(--violet-500); +} +.workflow-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + padding: 13px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +.workflow-strip > div { + display: flex; + align-items: center; + gap: 8px; +} +.workflow-strip span { + display: grid; + place-items: center; + width: 23px; + height: 23px; + border-radius: 50%; + background: var(--gradient-soft); + color: var(--violet-600); + font-size: 10px; + font-weight: 700; +} +.workflow-strip p { + font-size: 11px; +} +.workflow-strip svg { + margin-left: auto; + color: var(--muted); +} +.event-card.modern { + gap: 15px; +} +.event-card.modern .event-card-head { + align-items: flex-start; +} +.event-card.modern h3 { + font-size: 16px; +} +.event-progress { + display: grid; + gap: 6px; +} +.event-progress > div { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--muted); +} +.event-card footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + border-top: 1px solid var(--border); + font-size: 10px; + color: var(--muted); +} +.upload-layout { + display: grid; + grid-template-columns: 1.5fr 0.7fr; + gap: 16px; +} +.upload-guidance > div { + position: relative; + padding-left: 42px; +} +.upload-guidance h3 { + font-size: 13px; +} +.upload-guidance p { + color: var(--muted); + font-size: 11px; +} +.step-number { + position: absolute; + left: 0; + top: 0; + color: var(--violet-500); + font-size: 10px; + font-weight: 700; +} +.segmented { + display: inline-flex; + padding: 3px; + border-radius: var(--radius-md); + background: #e9edf4; +} +.segmented button { + padding: 6px 11px; + border: 0; + border-radius: 6px; + background: transparent; + font-size: 12px; + color: var(--muted); +} +.segmented button.active { + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-sm); +} +.photo-placeholder { + width: 38px; + height: 32px; + display: grid; + place-items: center; + border-radius: 6px; + background: linear-gradient(135deg, #d7e5ff, #f1dff4); + color: var(--violet-500); +} +.confidence-meter { + display: flex; + align-items: center; + gap: 8px; +} +.confidence-meter > span { + width: 70px; + height: 5px; + border-radius: 99px; + background: #edf0f5; + overflow: hidden; +} +.confidence-meter i { + display: block; + height: 100%; + background: var(--violet-500); +} +.confidence-meter strong { + font-size: 12px; +} +.row-actions { + display: flex; + gap: 4px; +} +.pipeline { + position: relative; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; + padding: 14px 0; +} +.pipeline-line { + position: absolute; + left: 8%; + right: 8%; + top: 34px; + height: 2px; + background: var(--border); +} +.pipeline-stage { + z-index: 1; + display: grid; + justify-items: center; + text-align: center; + gap: 3px; +} +.pipeline-stage > div { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border: 5px solid var(--surface); + border-radius: 50%; + background: #eef0f5; + color: var(--muted); + font-size: 11px; +} +.pipeline-stage > div.complete { + background: var(--success); + color: white; +} +.pipeline-stage > div.active { + background: var(--violet-500); + color: white; + box-shadow: 0 0 0 4px #e9e7ff; +} +.pipeline-stage strong { + font-size: 12px; +} +.pipeline-stage span, +.pipeline-stage small { + font-size: 10px; + color: var(--muted); +} +.confidence-list { + display: grid; + gap: 9px; +} +.confidence-list > div { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px; + padding: 12px; + border-left: 3px solid; + border-radius: 0 var(--radius-md) var(--radius-md) 0; + background: var(--surface-muted); +} +.confidence-list .high { + border-color: var(--success); +} +.confidence-list .review { + border-color: #e2a229; +} +.confidence-list .low { + border-color: var(--danger); +} +.confidence-list strong, +.confidence-list span { + font-size: 12px; +} +.confidence-list p { + grid-column: 1/-1; + color: var(--muted); + font-size: 11px; +} +.delivery-preview { + display: grid; + grid-template-columns: 1fr 1fr; + overflow: hidden; + background: linear-gradient(130deg, #141b42, #28277b); + color: white; + border: 0; +} +.delivery-copy { + padding: 32px; +} +.delivery-copy h3 { + margin-top: 10px; + font-size: 22px; +} +.delivery-copy > p { + margin: 8px 0 18px; + color: #c6c9df; + font-size: 13px; +} +.gallery-mosaic { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3px; + padding: 20px; + transform: rotate(-3deg) scale(1.04); +} +.gallery-mosaic div { + min-height: 80px; + display: grid; + place-items: center; + border-radius: 8px; + background: linear-gradient(145deg, #6e67e9, #ee77bd); + color: rgba(255, 255, 255, 0.7); +} +.settings-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.settings-nav { + display: grid; + padding: 7px; + position: sticky; + top: 20px; +} +.settings-nav button { + text-align: left; + padding: 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted); + font-size: 12px; +} +.settings-nav button.active { + background: var(--gradient-soft); + color: var(--violet-600); + font-weight: 600; +} +.settings-content { + display: grid; + gap: 16px; +} +.settings-save { + justify-self: start; +} +.locked-policy { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} +.locked-policy > div { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 9px; + padding: 14px; + border-radius: var(--radius-md); + background: var(--surface-muted); +} +.locked-policy svg { + grid-row: 1/3; + color: var(--violet-500); +} +.locked-policy span { + font-size: 11px; + color: var(--muted); +} +.locked-policy strong { + font-size: 13px; +} +.toggle-setting { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} +.toggle-setting:last-child { + border: 0; +} +.toggle-setting strong { + font-size: 13px; +} +.toggle-setting p { + font-size: 11px; + color: var(--muted); +} + +@media (max-width: 1200px) { + .stat-grid-wide { + grid-template-columns: repeat(2, 1fr); + } + .architecture-flow { + grid-template-columns: repeat(4, 1fr); + } + .architecture-layer:nth-child(4)::after { + display: none; + } + .admin-split { + grid-template-columns: 1fr; + } + .organization-detail { + position: static; + } + .funnel-grid { + grid-template-columns: repeat(3, 1fr); + } +} +@media (max-width: 760px) { + .stat-grid, + .stat-grid-wide { + grid-template-columns: 1fr 1fr; + } + .service-grid { + grid-template-columns: 1fr; + } + .architecture-flow { + grid-template-columns: 1fr; + } + .architecture-start::after, + .architecture-layer:not(:last-child)::after { + content: "↓"; + right: 50%; + top: auto; + bottom: -14px; + transform: translate(50%, 50%); + } + .form-grid { + grid-template-columns: 1fr; + } + .form-grid .full { + grid-column: auto; + } + .workflow-strip { + grid-template-columns: 1fr; + gap: 7px; + } + .workflow-strip svg { + transform: rotate(90deg); + } + .upload-layout, + .delivery-preview, + .settings-layout { + grid-template-columns: 1fr; + } + .pipeline { + grid-template-columns: 1fr; + padding-left: 0; + } + .pipeline-line { + left: 50%; + right: auto; + top: 8%; + bottom: 8%; + width: 2px; + height: auto; + } + .pipeline-stage { + background: var(--surface); + padding: 6px; + } + .locked-policy { + grid-template-columns: 1fr; + } + .funnel-grid { + grid-template-columns: 1fr 1fr; + } + .funnel-grid > div:not(:last-child)::after { + display: none; + } + .result-count { + width: 100%; + margin: 0; + } + .toolbar-search { + min-width: 100%; + } +} +@media (max-width: 480px) { + .stat-grid, + .stat-grid-wide { + grid-template-columns: 1fr; + } + .funnel-grid { + grid-template-columns: 1fr; + } + .retention-callout { + grid-template-columns: 1fr; + } + .modal-card { + max-height: calc(100vh - 18px); + } + .modal-backdrop { + padding: 9px; + } + .section { + padding: 17px; + } + .page { + gap: 17px; + } +} + +.page-state { + min-height: 180px; + padding: 32px; + display: grid; + place-items: center; + align-content: center; + gap: 8px; + text-align: center; + color: var(--muted); +} +.page-state.error { + color: var(--danger); + background: var(--danger-bg); +} +.page-state p { + font-size: 13px; +} +.state-spinner { + width: 26px; + height: 26px; + border: 3px solid var(--border); + border-top-color: var(--violet-500); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.public-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--gradient-mesh); +} +.public-card { + width: min(620px, 100%); + padding: 28px; + display: grid; + gap: 18px; +} +.public-brand { + display: flex; + align-items: center; + gap: 14px; +} +.public-card h1 { + font-size: 23px; +} +.event-summary { + display: grid; + padding: 14px; + border-radius: var(--radius-md); + background: var(--gradient-soft); +} +.event-summary span { + font-size: 12px; + color: var(--muted); +} +.camera-frame { + aspect-ratio: 4/3; + display: grid; + place-items: center; + overflow: hidden; + border-radius: var(--radius-lg); + background: #10152f; +} +.camera-frame video, +.camera-frame img { + width: 100%; + height: 100%; + object-fit: cover; +} +.camera-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.consent-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 13px; + border-radius: var(--radius-md); + background: var(--surface-muted); + font-size: 12px; + color: var(--muted); +} +.consent-row input { + margin-top: 3px; +} +.success-view { + text-align: center; + justify-items: center; +} +.success-mark { + width: 60px; + height: 60px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--success-bg); + color: var(--success); +} +.gallery-page { + min-height: 100vh; + padding: 28px clamp(18px, 5vw, 72px); + background: var(--bg); +} +.gallery-header { + display: flex; + align-items: center; + gap: 16px; +} +.gallery-header h1 { + font-size: 26px; +} +.gallery-header p { + color: var(--muted); + font-size: 13px; +} +.gallery-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin: 28px 0 14px; + color: var(--muted); + font-size: 12px; +} +.gallery-toolbar > div { + display: flex; + align-items: center; + gap: 12px; +} +.gallery-actions .btn { + color: inherit; + text-decoration: none; +} +.gallery-actions .primary { + color: #fff; +} +.photo-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} +.photo-gallery figure { + position: relative; + margin: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); +} +.photo-gallery figure.selected { + border-color: var(--violet-500); + box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.18); +} +.photo-select { + position: absolute; + z-index: 2; + top: 10px; + right: 10px; + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.75); + border-radius: 50%; + background: rgba(20, 27, 66, 0.72); + color: #fff; + cursor: pointer; +} +.photo-gallery img { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + display: block; +} +.photo-gallery figcaption { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + font-size: 12px; +} +.photo-gallery a, +.photo-gallery .link-button { + display: flex; + align-items: center; + gap: 5px; + border: 0; + background: transparent; + color: var(--violet-600); + font: inherit; + text-decoration: none; + cursor: pointer; +} +.btn.danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.table-error { + display: block; + max-width: 340px; + margin-top: 3px; + color: var(--danger); + font-size: 10px; + white-space: normal; +} +.settings-nav button:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/webapp/src/lib/api.js b/webapp/src/lib/api.js index 54dbf1b..603490d 100644 --- a/webapp/src/lib/api.js +++ b/webapp/src/lib/api.js @@ -12,7 +12,8 @@ function normalizeUser(user) { } function rememberUser(user) { - if (user) sessionStorage.setItem(USER_KEY, JSON.stringify(normalizeUser(user))); + if (user) + sessionStorage.setItem(USER_KEY, JSON.stringify(normalizeUser(user))); else sessionStorage.removeItem(USER_KEY); } @@ -60,7 +61,9 @@ async function refreshSession() { .then(async (response) => { if (!response.ok) throw new Error("Session expired"); const session = storeSession(await response.json()); - window.dispatchEvent(new CustomEvent("fdx:session-refreshed", { detail: session })); + window.dispatchEvent( + new CustomEvent("fdx:session-refreshed", { detail: session }), + ); return session; }) .catch((error) => { @@ -85,21 +88,35 @@ export async function initializeSession() { async function request(path, options = {}, retry = true) { const headers = new Headers(options.headers || {}); if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); - if (options.body && !(options.body instanceof FormData) && !headers.has("Content-Type")) { + if ( + options.body && + !(options.body instanceof FormData) && + !headers.has("Content-Type") + ) { headers.set("Content-Type", "application/json"); } - const response = await fetch(path, { ...options, headers, credentials: "include" }); + const response = await fetch(path, { + ...options, + headers, + credentials: "include", + }); if (response.status === 401 && retry && !path.endsWith("/auth/refresh")) { await refreshSession(); return request(path, options, false); } - const payload = response.headers.get("content-type")?.includes("application/json") ? await response.json() : null; + const payload = response.headers + .get("content-type") + ?.includes("application/json") + ? await response.json() + : null; if (!response.ok) { const validation = payload?.error?.details?.errors ?? payload?.detail; const detail = Array.isArray(validation) ? validation.map((item) => item.msg).join(" · ") - : payload?.error?.message ?? validation; - throw new Error(detail || payload?.message || `Request failed (${response.status})`); + : (payload?.error?.message ?? validation); + throw new Error( + detail || payload?.message || `Request failed (${response.status})`, + ); } return payload; } @@ -117,15 +134,30 @@ export async function directUpload(url, file, headers = {}) { // application cookies or the API bearer token would unnecessarily widen the // browser CORS contract and can cause S3 to reject an otherwise valid PUT. const response = await fetch(url, { method: "PUT", headers, body: file }); - if (!response.ok) throw new Error(`Direct upload failed (${response.status})`); + if (!response.ok) + throw new Error(`Direct upload failed (${response.status})`); return null; } +export async function directUploadPart(url, bytes) { + const response = await fetch(url, { method: "PUT", body: bytes }); + if (!response.ok) + throw new Error(`Multipart upload failed (${response.status})`); + const etag = response.headers.get("etag"); + if (!etag) + throw new Error("Object storage did not expose the multipart ETag header"); + return etag; +} + export async function loginRequest(email, password) { - const payload = await request("/api/v2/auth/login", { - method: "POST", - body: JSON.stringify({ email, password }), - }, false); + const payload = await request( + "/api/v2/auth/login", + { + method: "POST", + body: JSON.stringify({ email, password }), + }, + false, + ); return storeSession(payload); } diff --git a/webapp/src/pages/Login.jsx b/webapp/src/pages/Login.jsx index 95ed1f3..34a473d 100644 --- a/webapp/src/pages/Login.jsx +++ b/webapp/src/pages/Login.jsx @@ -14,7 +14,12 @@ export default function Login() { if (loading) return null; if (isAuthenticated) { - return ; + return ( + + ); } async function handleSubmit(event) { @@ -23,7 +28,10 @@ export default function Login() { setSubmitting(true); try { const { user: loggedInUser } = await login(email, password); - navigate(loggedInUser.role === "super_admin" ? "/admin" : "/organization", { replace: true }); + navigate( + loggedInUser.role === "super_admin" ? "/admin" : "/organization", + { replace: true }, + ); } catch (err) { setError(err.message); } finally { @@ -41,7 +49,9 @@ export default function Login() {

Sign in

-

One secure login for FDX and organization administrators.

+

+ One secure login for FDX and organization administrators. +

@@ -69,14 +79,23 @@ export default function Login() { />
- {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} - - Forgot password? - + + Forgot password? +
diff --git a/webapp/src/pages/organization/Logs.jsx b/webapp/src/pages/organization/Logs.jsx index 1795834..909ffd4 100644 --- a/webapp/src/pages/organization/Logs.jsx +++ b/webapp/src/pages/organization/Logs.jsx @@ -1 +1,12 @@ -import LogsTable from "../../components/LogsTable";import {usePlatform} from "../../context/PlatformContext";export default function OrganizationLogs(){const{logs}=usePlatform();return } +import LogsTable from "../../components/LogsTable"; +import { usePlatform } from "../../context/PlatformContext"; +export default function OrganizationLogs() { + const { logs } = usePlatform(); + return ( + + ); +} diff --git a/webapp/src/pages/organization/Overview.jsx b/webapp/src/pages/organization/Overview.jsx index 4a392e6..e4c6f14 100644 --- a/webapp/src/pages/organization/Overview.jsx +++ b/webapp/src/pages/organization/Overview.jsx @@ -1,2 +1,149 @@ -import Badge from "../../components/Badge";import Gauge from "../../components/Gauge";import PageState from "../../components/PageState";import StatCard from "../../components/StatCard";import {useAuth} from "../../context/AuthContext";import {usePlatform} from "../../context/PlatformContext"; -export default function OrganizationOverview(){const{user}=useAuth();const{dashboard,loading,error}=usePlatform();const org=dashboard?.organization;const events=dashboard?.events??[];const stats=dashboard?.stats;return

Organization overview

Welcome back, {user?.name?.split(" ")[0]}

{org?.name??user?.organizationName} · Live event photo operations.

{org?
{org.retentionDays}-day retention{org.storageUsedGB}/{org.storageLimitGB} GB
:null}
{stats&&org?<>

Event operations

Current progress from upload through delivery

View events
{events.length?
{events.slice(0,5).map(event=>
{new Date(`${event.date}T00:00:00`).getDate()}{new Date(`${event.date}T00:00:00`).toLocaleString("en",{month:"short"})}
{event.name}{event.location||"No location"} · {event.photos.toLocaleString()} photos
)}
:

Create your first event to start the workflow.

}

Storage & retention

Controlled by your FDX administrator

Next data expiry{org.nextDataExpiry??"No events"}
Retention policy{org.retentionDays} days
:null}
} +import Badge from "../../components/Badge"; +import Gauge from "../../components/Gauge"; +import PageState from "../../components/PageState"; +import StatCard from "../../components/StatCard"; +import { useAuth } from "../../context/AuthContext"; +import { usePlatform } from "../../context/PlatformContext"; +export default function OrganizationOverview() { + const { user } = useAuth(); + const { dashboard, loading, error } = usePlatform(); + const org = dashboard?.organization; + const events = dashboard?.events ?? []; + const stats = dashboard?.stats; + return ( +
+
+
+

Organization overview

+

Welcome back, {user?.name?.split(" ")[0]}

+

+ {org?.name ?? user?.organizationName} · Live event photo operations. +

+
+ {org ? ( +
+ {org.retentionDays}-day retention + + {org.storageUsedGB}/{org.storageLimitGB} GB + +
+ ) : null} +
+ + {stats && org ? ( + <> +
+ + + + +
+
+
+
+
+

Event operations

+

Current progress from upload through delivery

+
+ + View events + +
+ {events.length ? ( +
+ {events.slice(0, 5).map((event) => ( +
+
+ + {new Date(`${event.date}T00:00:00`).getDate()} + + + {new Date(`${event.date}T00:00:00`).toLocaleString( + "en", + { month: "short" }, + )} + +
+
+ {event.name} + + {event.location || "No location"} ·{" "} + {event.photos.toLocaleString()} photos + +
+ +
+
+ +
+ ))} +
+ ) : ( +

+ Create your first event to start the workflow. +

+ )} +
+
+
+
+

Storage & retention

+

Controlled by your FDX administrator

+
+
+
+ + +
+
+
+ Next data expiry + {org.nextDataExpiry ?? "No events"} +
+
+ Retention policy + {org.retentionDays} days +
+
+
+
+ + ) : null} +
+
+ ); +} diff --git a/webapp/src/pages/organization/Participants.jsx b/webapp/src/pages/organization/Participants.jsx index e45d797..1f888ff 100644 --- a/webapp/src/pages/organization/Participants.jsx +++ b/webapp/src/pages/organization/Participants.jsx @@ -6,7 +6,12 @@ import Modal from "../../components/Modal"; import StatCard from "../../components/StatCard"; import { usePlatform } from "../../context/PlatformContext"; export default function Participants() { - const { events, participants, validateParticipantImport, confirmParticipantImport } = usePlatform(); + const { + events, + participants, + validateParticipantImport, + confirmParticipantImport, + } = usePlatform(); const [eventId, setEventId] = useState(""); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); @@ -197,7 +202,10 @@ export default function Participants() { hint="Drop .csv, .xlsx or click to browse" accept=".csv,.xls,.xlsx,.xlsm" multiple={false} - onFiles={(files) => { setQueued(files[0]); setPreview(null); }} + onFiles={(files) => { + setQueued(files[0]); + setPreview(null); + }} /> {queued ? (
@@ -209,9 +217,18 @@ export default function Participants() {
) : null} {preview ? ( -
- {preview.valid_rows} valid · {preview.duplicate_rows} duplicate · {preview.invalid_rows} invalid - {preview.errors?.slice(0, 5).map((row) =>

Row {row.row}: {row.errors.join(", ")}

)} +
+ {preview.valid_rows} valid · {preview.duplicate_rows} duplicate ·{" "} + {preview.invalid_rows} invalid + {preview.errors?.slice(0, 5).map((row) => ( +

+ Row {row.row}: {row.errors.join(", ")} +

+ ))}
) : null}
diff --git a/webapp/src/pages/organization/Processing.jsx b/webapp/src/pages/organization/Processing.jsx index 9ad516f..4c7d71b 100644 --- a/webapp/src/pages/organization/Processing.jsx +++ b/webapp/src/pages/organization/Processing.jsx @@ -1,2 +1,103 @@ -import Badge from "../../components/Badge";import StatCard from "../../components/StatCard";import {usePlatform} from "../../context/PlatformContext"; -export default function Processing(){const{jobs,processingStats}=usePlatform();const stats=processingStats??{};return

ML pipeline

Processing

Live Kafka job state from ingestion through secure face matching.

Worker monitoring active
x.status==="completed").length} hint="Stored in PostgreSQL"/>

Processing jobs

Kafka worker assignments and persistent progress

{jobs.map(job=>
{job.type} · {job.photoId?.slice(0,8)}{job.error||new Date(job.createdAt).toLocaleString()}
{job.worker}
)}{!jobs.length?

Upload event photos to create processing jobs.

:null}

Confidence policy

Conservative matching protects participant privacy

High confidence≥ 85%

Automatically assigned when runner-up margin is safe

Needs review65–84%

Held for organization admin verification

Low confidence< 65%

Kept unknown and never delivered

} +import Badge from "../../components/Badge"; +import StatCard from "../../components/StatCard"; +import { usePlatform } from "../../context/PlatformContext"; +export default function Processing() { + const { jobs, processingStats } = usePlatform(); + const stats = processingStats ?? {}; + return ( +
+
+
+

ML pipeline

+

Processing

+

+ Live Kafka job state from ingestion through secure face matching. +

+
+
+ Worker monitoring active +
+
+
+ + + x.status === "completed").length} + hint="Stored in PostgreSQL" + /> + +
+
+
+
+

Processing jobs

+

Kafka worker assignments and persistent progress

+
+
+
+ {jobs.map((job) => ( +
+
+ + {job.type} · {job.photoId?.slice(0, 8)} + + + {job.error || new Date(job.createdAt).toLocaleString()} + +
+ {job.worker} + +
+ ))} + {!jobs.length ? ( +

+ Upload event photos to create processing jobs. +

+ ) : null} +
+
+
+
+
+

Confidence policy

+

Conservative matching protects participant privacy

+
+
+
+
+ High confidence + ≥ 85% +

Automatically assigned when runner-up margin is safe

+
+
+ Needs review + 65–84% +

Held for organization admin verification

+
+
+ Low confidence + < 65% +

Kept unknown and never delivered

+
+
+
+
+ ); +} diff --git a/webapp/src/pages/public/AcceptInvite.jsx b/webapp/src/pages/public/AcceptInvite.jsx index 08868e7..e3be90e 100644 --- a/webapp/src/pages/public/AcceptInvite.jsx +++ b/webapp/src/pages/public/AcceptInvite.jsx @@ -1,2 +1,79 @@ -import {useState} from "react";import {Link,useNavigate,useParams} from "react-router-dom";import {useAuth} from "../../context/AuthContext"; -export default function AcceptInvite(){const{token}=useParams();const navigate=useNavigate();const{setAuthenticatedSession}=useAuth();const[password,setPassword]=useState("");const[confirm,setConfirm]=useState("");const[error,setError]=useState("");const[submitting,setSubmitting]=useState(false);async function submit(event){event.preventDefault();if(password!==confirm){setError("Passwords do not match.");return}setSubmitting(true);setError("");try{const response=await fetch(`/api/v2/auth/invitations/${token}/accept`,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify({password})});const payload=await response.json();if(!response.ok)throw new Error(payload?.error?.message||"Invitation could not be accepted");setAuthenticatedSession(payload);navigate("/organization",{replace:true})}catch(err){setError(err.message)}finally{setSubmitting(false)}}return
FDX

Secure invitation

Create your password

Activate your Organization Admin account.

setPassword(e.target.value)}/>
setConfirm(e.target.value)}/>
{error?

{error}

:null}Back to sign in
} +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { useAuth } from "../../context/AuthContext"; +export default function AcceptInvite() { + const { token } = useParams(); + const navigate = useNavigate(); + const { setAuthenticatedSession } = useAuth(); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + async function submit(event) { + event.preventDefault(); + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + setSubmitting(true); + setError(""); + try { + const response = await fetch(`/api/v2/auth/invitations/${token}/accept`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ password }), + }); + const payload = await response.json(); + if (!response.ok) + throw new Error( + payload?.error?.message || "Invitation could not be accepted", + ); + setAuthenticatedSession(payload); + navigate("/organization", { replace: true }); + } catch (err) { + setError(err.message); + } finally { + setSubmitting(false); + } + } + return ( +
+
+ FDX +
+

Secure invitation

+

Create your password

+

Activate your Organization Admin account.

+
+
+ + setPassword(e.target.value)} + /> +
+
+ + setConfirm(e.target.value)} + /> +
+ {error ?

{error}

: null} + + + Back to sign in + +
+
+ ); +} diff --git a/webapp/src/pages/public/Enrollment.jsx b/webapp/src/pages/public/Enrollment.jsx index 07359ae..43bc4c3 100644 --- a/webapp/src/pages/public/Enrollment.jsx +++ b/webapp/src/pages/public/Enrollment.jsx @@ -1,7 +1,17 @@ import { useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import Icon from "../../components/Icon"; -import { api } from "../../lib/api"; +import { api, directUpload } from "../../lib/api"; + +async function sha256(file) { + const digest = await crypto.subtle.digest( + "SHA-256", + await file.arrayBuffer(), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} export default function Enrollment() { const { token } = useParams(); @@ -17,20 +27,32 @@ export default function Enrollment() { const [submitting, setSubmitting] = useState(false); useEffect(() => { - api(`/v2/public/enrollment/${token}`).then((response) => setInfo(response.data)).catch((requestError) => setError(requestError.message)); - return () => streamRef.current?.getTracks().forEach((track) => track.stop()); + api(`/v2/public/enrollment/${token}`) + .then((response) => setInfo(response.data)) + .catch((requestError) => setError(requestError.message)); + return () => + streamRef.current?.getTracks().forEach((track) => track.stop()); }, [token]); async function camera() { try { - const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 720 }, height: { ideal: 720 } }, audio: false }); + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: "user", + width: { ideal: 720 }, + height: { ideal: 720 }, + }, + audio: false, + }); streamRef.current = stream; videoRef.current.srcObject = stream; await videoRef.current.play(); setCameraReady(true); setError(""); } catch (requestError) { - setError(`Camera unavailable: ${requestError.message}. You can choose a selfie file instead.`); + setError( + `Camera unavailable: ${requestError.message}. You can choose a selfie file instead.`, + ); } } @@ -41,12 +63,16 @@ export default function Enrollment() { canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext("2d").drawImage(video, 0, 0); - canvas.toBlob((blob) => { - setImage(new File([blob], "selfie.jpg", { type: "image/jpeg" })); - setPreview(URL.createObjectURL(blob)); - streamRef.current?.getTracks().forEach((track) => track.stop()); - setCameraReady(false); - }, "image/jpeg", 0.92); + canvas.toBlob( + (blob) => { + setImage(new File([blob], "selfie.jpg", { type: "image/jpeg" })); + setPreview(URL.createObjectURL(blob)); + streamRef.current?.getTracks().forEach((track) => track.stop()); + setCameraReady(false); + }, + "image/jpeg", + 0.92, + ); } async function submit() { @@ -55,25 +81,129 @@ export default function Enrollment() { setError(""); const consentBody = new FormData(); consentBody.append("accepted", "true"); - const selfieBody = new FormData(); - selfieBody.append("selfie", image); try { - await api(`/v2/public/enrollment/${token}/consent`, { method: "POST", body: consentBody }); - await api(`/v2/public/enrollment/${token}/complete`, { method: "POST", body: selfieBody }); + await api(`/v2/public/enrollment/${token}/consent`, { + method: "POST", + body: consentBody, + }); + const upload = await api(`/v2/public/enrollment/${token}/upload-url`, { + method: "POST", + body: JSON.stringify({ + filename: image.name, + content_type: image.type, + size_bytes: image.size, + sha256: await sha256(image), + }), + }); + await directUpload(upload.data.upload_url, image, upload.data.headers); + await api(`/v2/public/enrollment/${token}/complete`, { method: "POST" }); setDone(true); + } catch (requestError) { + setError(requestError.message); + } finally { + setSubmitting(false); } - catch (requestError) { setError(requestError.message); } - finally { setSubmitting(false); } } - if (done) return

Face verified securely

FDX will email your private gallery when matching is complete. No account is required.

; - return
-
FDX

Participant verification

Find your event photos

- {info ?
{info.event_name}{info.organization_name} · For {info.participant_name}
: null} - {error ?

{error}

: null} -
{preview ? Captured selfie :
-
{preview ? : <>}
- - -
; + if (done) + return ( +
+
+ + + +

Face verified securely

+

+ FDX will email your private gallery when matching is complete. No + account is required. +

+
+
+ ); + return ( +
+
+
+ FDX +
+

Participant verification

+

Find your event photos

+
+
+ {info ? ( +
+ {info.event_name} + + {info.organization_name} · For {info.participant_name} + +
+ ) : null} + {error ?

{error}

: null} +
+ {preview ? ( + Captured selfie + ) : ( +
+
+ {preview ? ( + + ) : ( + <> + + + + + )} +
+ + +
+
+ ); } diff --git a/webapp/src/pages/public/ForgotPassword.jsx b/webapp/src/pages/public/ForgotPassword.jsx index 38260cb..b729996 100644 --- a/webapp/src/pages/public/ForgotPassword.jsx +++ b/webapp/src/pages/public/ForgotPassword.jsx @@ -16,7 +16,9 @@ export default function ForgotPassword() { }); const payload = await response.json(); if (!response.ok) { - setError(payload?.error?.message ?? "Password reset could not be requested."); + setError( + payload?.error?.message ?? "Password reset could not be requested.", + ); return; } setMessage(payload.data.message); @@ -26,12 +28,38 @@ export default function ForgotPassword() {
FDX -

Account recovery

Reset password

Enter your account email to receive a secure reset link.

-
setEmail(event.target.value)} />
- {message ?

{message}

: null} - {error ?

{error}

: null} +
+

Account recovery

+

Reset password

+

+ Enter your account email to receive a secure reset link. +

+
+
+ + setEmail(event.target.value)} + /> +
+ {message ? ( +

+ {message} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} - Back to sign in + + Back to sign in +
); diff --git a/webapp/src/pages/public/Gallery.jsx b/webapp/src/pages/public/Gallery.jsx index 0d34714..3ab0532 100644 --- a/webapp/src/pages/public/Gallery.jsx +++ b/webapp/src/pages/public/Gallery.jsx @@ -15,21 +15,31 @@ export default function Gallery() { .catch((requestError) => setError(requestError.message)); }, [token]); - const pollExport = useCallback(async (exportId) => { - for (let attempt = 0; attempt < 60; attempt += 1) { - const response = await api(`/v2/public/gallery/${token}/exports/${exportId}`); - setExportJob(response.data); - if (response.data.status === "READY") return response.data; - if (["FAILED", "EXPIRED"].includes(response.data.status)) throw new Error(response.data.error || "Gallery export failed"); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - throw new Error("Gallery export is still being prepared. Please try again shortly."); - }, [token]); + const pollExport = useCallback( + async (exportId) => { + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await api( + `/v2/public/gallery/${token}/exports/${exportId}`, + ); + setExportJob(response.data); + if (response.data.status === "READY") return response.data; + if (["FAILED", "EXPIRED"].includes(response.data.status)) + throw new Error(response.data.error || "Gallery export failed"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error( + "Gallery export is still being prepared. Please try again shortly.", + ); + }, + [token], + ); async function downloadAll() { try { setError(""); - const response = await api(`/v2/public/gallery/${token}/exports`, { method: "POST" }); + const response = await api(`/v2/public/gallery/${token}/exports`, { + method: "POST", + }); setExportJob(response.data); const ready = await pollExport(response.data.export_id); window.location.assign(ready.download_url); @@ -66,13 +76,23 @@ export default function Gallery() {
{data.photos.length} matched photos - Expires {new Date(data.expires_at).toLocaleDateString()} + + Expires {new Date(data.expires_at).toLocaleDateString()} +
{data.photos.length ? (
-
) : null} @@ -80,17 +100,28 @@ export default function Gallery() {
{data.photos.map((photo) => (
- {photo.filename} + {photo.filename}
{photo.filename} -
))}
- {!data.photos.length ?
No approved photos are available.
: null} + {!data.photos.length ? ( +
+ No approved photos are available. +
+ ) : null} ) : null}
diff --git a/webapp/src/pages/public/ResetPassword.jsx b/webapp/src/pages/public/ResetPassword.jsx index 0078b2c..4723176 100644 --- a/webapp/src/pages/public/ResetPassword.jsx +++ b/webapp/src/pages/public/ResetPassword.jsx @@ -31,12 +31,43 @@ export default function ResetPassword() {
FDX -

Secure reset

Choose a new password

-
setPassword(event.target.value)} />
-
setConfirm(event.target.value)} />
- {error ?

{error}

: null} +
+

Secure reset

+

Choose a new password

+
+
+ + setPassword(event.target.value)} + /> +
+
+ + setConfirm(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} - Cancel + + Cancel +
); diff --git a/webapp/src/pages/superadmin/Logs.jsx b/webapp/src/pages/superadmin/Logs.jsx index 36d0bf2..afd6416 100644 --- a/webapp/src/pages/superadmin/Logs.jsx +++ b/webapp/src/pages/superadmin/Logs.jsx @@ -1 +1,12 @@ -import LogsTable from "../../components/LogsTable";import {usePlatform} from "../../context/PlatformContext";export default function SuperAdminLogs(){const{logs}=usePlatform();return } +import LogsTable from "../../components/LogsTable"; +import { usePlatform } from "../../context/PlatformContext"; +export default function SuperAdminLogs() { + const { logs } = usePlatform(); + return ( + + ); +} diff --git a/webapp/src/pages/superadmin/OrganizationUsers.jsx b/webapp/src/pages/superadmin/OrganizationUsers.jsx index 3d57401..30ca44c 100644 --- a/webapp/src/pages/superadmin/OrganizationUsers.jsx +++ b/webapp/src/pages/superadmin/OrganizationUsers.jsx @@ -5,34 +5,189 @@ import Modal from "../../components/Modal"; import { usePlatform } from "../../context/PlatformContext"; export default function OrganizationUsers() { - const { organizations, organizationUsers, addOrganizationUser } = usePlatform(); + const { organizations, organizationUsers, addOrganizationUser } = + usePlatform(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [notice, setNotice] = useState(""); const [form, setForm] = useState({ name: "", email: "", organizationId: "" }); - const visible = useMemo(() => organizationUsers.filter((item) => `${item.name} ${item.email} ${item.organization}`.toLowerCase().includes(query.toLowerCase())), [organizationUsers, query]); - const selectedOrganizationId = form.organizationId || organizations[0]?.id || ""; + const visible = useMemo( + () => + organizationUsers.filter((item) => + `${item.name} ${item.email} ${item.organization}` + .toLowerCase() + .includes(query.toLowerCase()), + ), + [organizationUsers, query], + ); + const selectedOrganizationId = + form.organizationId || organizations[0]?.id || ""; async function submit(event) { event.preventDefault(); - const invited = await addOrganizationUser({ ...form, organizationId: selectedOrganizationId }); - setNotice(invited.developmentInviteUrl ? `Invite sent. Development link: ${invited.developmentInviteUrl}` : `Invite queued for ${form.email}`); + const invited = await addOrganizationUser({ + ...form, + organizationId: selectedOrganizationId, + }); + setNotice( + invited.developmentInviteUrl + ? `Invite sent. Development link: ${invited.developmentInviteUrl}` + : `Invite queued for ${form.email}`, + ); setOpen(false); - setForm({ name: "", email: "", organizationId: organizations[0]?.id ?? "" }); + setForm({ + name: "", + email: "", + organizationId: organizations[0]?.id ?? "", + }); } - return
-

Access management

Organization users

Create tenant administrators and track secure invitation status.

- {notice ?
{notice}
: null} -
setQuery(event.target.value)} placeholder="Search users or organizations" />
{visible.length} users
-
{visible.map((user) => )}
UserOrganizationRoleAccountInvitationLast active
{user.name.split(" ").map((part) => part[0]).slice(0, 2).join("")}
{user.name}{user.email}
{user.organization}Organization Admin{user.lastActive}
{!visible.length ?

No organization administrators found.

: null}
- setOpen(false)} title="Invite organization admin" description="The recipient will receive a secure link to set their password." footer={<>}> -
-
setForm({ ...form, name: event.target.value })} />
-
setForm({ ...form, email: event.target.value })} />
-
-
Organization Admin

Can manage events, participants, photos, matches and deliveries only within the selected organization.

-
-
-
; + return ( +
+
+
+

Access management

+

Organization users

+

+ Create tenant administrators and track secure invitation status. +

+
+ +
+ {notice ? ( +
+ + {notice} +
+ ) : null} +
+
+ + setQuery(event.target.value)} + placeholder="Search users or organizations" + /> +
+ {visible.length} users +
+
+ + + + + + + + + + + + + {visible.map((user) => ( + + + + + + + + + ))} + +
UserOrganizationRoleAccountInvitationLast active
+
+ + {user.name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join("")} + +
+ {user.name} + {user.email} +
+
+
{user.organization}Organization Admin + + + + {user.lastActive}
+ {!visible.length ? ( +

No organization administrators found.

+ ) : null} +
+ setOpen(false)} + title="Invite organization admin" + description="The recipient will receive a secure link to set their password." + footer={ + <> + + + + } + > +
+
+ + + setForm({ ...form, name: event.target.value }) + } + /> +
+
+ + + setForm({ ...form, email: event.target.value }) + } + /> +
+
+ + +
+
+ Organization Admin +

+ Can manage events, participants, photos, matches and deliveries + only within the selected organization. +

+
+
+
+
+ ); } diff --git a/webapp/src/pages/superadmin/Organizations.jsx b/webapp/src/pages/superadmin/Organizations.jsx index de1dd5b..ccd0f67 100644 --- a/webapp/src/pages/superadmin/Organizations.jsx +++ b/webapp/src/pages/superadmin/Organizations.jsx @@ -7,18 +7,72 @@ import Toggle from "../../components/Toggle"; import { usePlatform } from "../../context/PlatformContext"; import "./Organizations.css"; -const defaultExpiry = () => { const value = new Date(); value.setFullYear(value.getFullYear() + 1); return value.toISOString().slice(0, 10); }; -const emptyForm = () => ({ name: "", type: "COLLEGE", contactName: "", contactEmail: "", phone: "", storageLimitGB: 100, retentionDays: 90, expiry: defaultExpiry() }); +const defaultExpiry = () => { + const value = new Date(); + value.setFullYear(value.getFullYear() + 1); + return value.toISOString().slice(0, 10); +}; +const emptyForm = () => ({ + name: "", + type: "COLLEGE", + contactName: "", + contactEmail: "", + phone: "", + storageLimitGB: 100, + retentionDays: 90, + expiry: defaultExpiry(), +}); function PolicyEditor({ organization, update }) { const [quota, setQuota] = useState(organization.storageLimitGB); const [retention, setRetention] = useState(organization.retentionDays); const [expiry, setExpiry] = useState(organization.expiry ?? ""); - return
-
setQuota(event.target.value)} onBlur={() => Number(quota) >= Math.max(1, organization.storageUsedGB) && Number(quota) !== organization.storageLimitGB && update(organization.id, { storageLimitGB: Number(quota) })} />
-
setRetention(event.target.value)} onBlur={() => Number(retention) >= 1 && Number(retention) <= 3650 && Number(retention) !== organization.retentionDays && update(organization.id, { retentionDays: Number(retention) })} />
-
setExpiry(event.target.value)} onBlur={() => expiry !== (organization.expiry ?? "") && update(organization.id, { expiry: expiry || null })} />
-
; + return ( +
+
+ + setQuota(event.target.value)} + onBlur={() => + Number(quota) >= Math.max(1, organization.storageUsedGB) && + Number(quota) !== organization.storageLimitGB && + update(organization.id, { storageLimitGB: Number(quota) }) + } + /> +
+
+ + setRetention(event.target.value)} + onBlur={() => + Number(retention) >= 1 && + Number(retention) <= 3650 && + Number(retention) !== organization.retentionDays && + update(organization.id, { retentionDays: Number(retention) }) + } + /> +
+
+ + setExpiry(event.target.value)} + onBlur={() => + expiry !== (organization.expiry ?? "") && + update(organization.id, { expiry: expiry || null }) + } + /> +
+
+ ); } export default function Organizations() { @@ -28,8 +82,19 @@ export default function Organizations() { const [type, setType] = useState("ALL"); const [open, setOpen] = useState(false); const [form, setForm] = useState(emptyForm); - const selected = organizations.find((item) => item.id === selectedId) ?? organizations[0]; - const visible = useMemo(() => organizations.filter((item) => (type === "ALL" || item.type === type) && `${item.name} ${item.contactEmail}`.toLowerCase().includes(query.toLowerCase())), [organizations, query, type]); + const selected = + organizations.find((item) => item.id === selectedId) ?? organizations[0]; + const visible = useMemo( + () => + organizations.filter( + (item) => + (type === "ALL" || item.type === type) && + `${item.name} ${item.contactEmail}` + .toLowerCase() + .includes(query.toLowerCase()), + ), + [organizations, query, type], + ); async function submit(event) { event.preventDefault(); @@ -39,24 +104,265 @@ export default function Organizations() { setOpen(false); } - return
-

Tenant management

Organizations

Create colleges and companies, control access, quotas and retention.

-
setQuery(event.target.value)} placeholder="Search organizations" />
{visible.length} organizations
-
-
{visible.map((organization) => setSelectedId(organization.id)}>)}
OrganizationTypeStatusUsageNext expiry
{organization.name.slice(0, 2).toUpperCase()}
{organization.name}{organization.contactEmail}
{organization.type === "COLLEGE" ? "College" : "Company"}
{organization.storageUsedGB}/{organization.storageLimitGB} GB
{organization.nextDataExpiry ?? "—"}
{!visible.length ?

No organizations found.

: null}
- {selected ? : null} + return ( +
+
+
+

Tenant management

+

Organizations

+

+ Create colleges and companies, control access, quotas and retention. +

+
+ +
+
+
+ + setQuery(event.target.value)} + placeholder="Search organizations" + /> +
+ + {visible.length} organizations +
+
+
+ + + + + + + + + + + + {visible.map((organization) => ( + setSelectedId(organization.id)} + > + + + + + + + ))} + +
OrganizationTypeStatusUsageNext expiry
+
+ + {organization.name.slice(0, 2).toUpperCase()} + +
+ {organization.name} + {organization.contactEmail} +
+
+
+ {organization.type === "COLLEGE" ? "College" : "Company"} + + + +
+ + + + {organization.storageUsedGB}/{organization.storageLimitGB}{" "} + GB +
+
{organization.nextDataExpiry ?? "—"}
+ {!visible.length ? ( +

No organizations found.

+ ) : null} +
+ {selected ? ( + + ) : null} +
+ setOpen(false)} + title="Create organization" + description="Provision a college or company tenant with its own policy boundaries." + footer={ + <> + + + + } + > +
+
+ + + setForm({ ...form, name: event.target.value }) + } + placeholder="e.g. Chennai Institute of Technology" + /> +
+
+ + +
+
+ + + setForm({ ...form, contactName: event.target.value }) + } + /> +
+
+ + + setForm({ ...form, contactEmail: event.target.value }) + } + /> +
+
+ + + setForm({ ...form, phone: event.target.value }) + } + /> +
+
+ + + setForm({ ...form, storageLimitGB: Number(event.target.value) }) + } + /> +
+
+ + + setForm({ ...form, retentionDays: Number(event.target.value) }) + } + /> +
+
+ + + setForm({ ...form, expiry: event.target.value }) + } + /> +
+
+
- setOpen(false)} title="Create organization" description="Provision a college or company tenant with its own policy boundaries." footer={<>}> -
-
setForm({ ...form, name: event.target.value })} placeholder="e.g. Chennai Institute of Technology" />
-
-
setForm({ ...form, contactName: event.target.value })} />
-
setForm({ ...form, contactEmail: event.target.value })} />
-
setForm({ ...form, phone: event.target.value })} />
-
setForm({ ...form, storageLimitGB: Number(event.target.value) })} />
-
setForm({ ...form, retentionDays: Number(event.target.value) })} />
-
setForm({ ...form, expiry: event.target.value })} />
-
-
-
; + ); } diff --git a/webapp/src/pages/superadmin/Overview.jsx b/webapp/src/pages/superadmin/Overview.jsx index a5d8a1f..0d38d99 100644 --- a/webapp/src/pages/superadmin/Overview.jsx +++ b/webapp/src/pages/superadmin/Overview.jsx @@ -8,5 +8,185 @@ export default function SuperAdminOverview() { const { user } = useAuth(); const { dashboard, loading, error } = usePlatform(); const stats = dashboard?.stats; - return

Platform overview

Good morning, {user?.name?.split(" ")[0]}

Live operational data from the FDX platform.

Live platform data
{stats ? <>
x.status==="healthy")?"Healthy":"Degraded"} hint="Live dependency checks" />

Organization usage

Storage, event volume and account status

Manage all
{dashboard.organizations.length ?
{dashboard.organizations.map(org=>{const percent=org.storageLimitGB?Math.round(org.storageUsedGB/org.storageLimitGB*100):0;return
{org.name.slice(0,2).toUpperCase()}
{org.name}{org.type.toLowerCase()} · {org.events} events
{percent}%{org.storageUsedGB}/{org.storageLimitGB} GB
})}
:

Create an organization to begin onboarding.

}

Recent activity

Security and operational audit trail

{dashboard.logs.length?
{dashboard.logs.map(log=>

{log.action}

{log.details}

{log.actor} · {new Date(log.timestamp).toLocaleString()}

)}
:

No platform activity yet.

}

Service status

Live application and infrastructure health

x.status==="healthy")?"healthy":"degraded"}/>
{dashboard.services.map(service=>
{service.name}

{service.detail}

)}
:null}
; + return ( +
+
+
+

Platform overview

+

Good morning, {user?.name?.split(" ")[0]}

+

Live operational data from the FDX platform.

+
+
+ Live platform data +
+
+ + {stats ? ( + <> +
+ + + + + + + + x.status === "healthy") + ? "Healthy" + : "Degraded" + } + hint="Live dependency checks" + /> +
+
+
+
+
+

Organization usage

+

Storage, event volume and account status

+
+ + Manage all + +
+ {dashboard.organizations.length ? ( +
+ {dashboard.organizations.map((org) => { + const percent = org.storageLimitGB + ? Math.round( + (org.storageUsedGB / org.storageLimitGB) * 100, + ) + : 0; + return ( +
+
+ {org.name.slice(0, 2).toUpperCase()} +
+
+
+ {org.name} + + {org.type.toLowerCase()} · {org.events} events + +
+
+ +
+
+
+ {percent}% + + {org.storageUsedGB}/{org.storageLimitGB} GB + +
+
+ ); + })} +
+ ) : ( +

+ Create an organization to begin onboarding. +

+ )} +
+
+
+
+

Recent activity

+

Security and operational audit trail

+
+
+ {dashboard.logs.length ? ( +
+ {dashboard.logs.map((log) => ( +
+ +
+

{log.action}

+

{log.details}

+

+ {log.actor} ·{" "} + {new Date(log.timestamp).toLocaleString()} +

+
+
+ ))} +
+ ) : ( +

No platform activity yet.

+ )} +
+
+
+
+
+

Service status

+

Live application and infrastructure health

+
+ x.status === "healthy") + ? "healthy" + : "degraded" + } + /> +
+
+ {dashboard.services.map((service) => ( +
+ +
+ {service.name} +

{service.detail}

+
+
+ ))} +
+
+ + ) : null} +
+
+ ); } diff --git a/webapp/vite.config.js b/webapp/vite.config.js index 17e7c63..dd11e4f 100644 --- a/webapp/vite.config.js +++ b/webapp/vite.config.js @@ -1,12 +1,12 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': 'http://127.0.0.1:8000', + "/api": "http://127.0.0.1:8000", }, }, -}) +});

Set your password