diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..3107fd1d9fffde73983a98377364773d34157124 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +data +.git +*.log +server.pid +__pycache__ +*.pyc +.DS_Store +Dockerfile +.dockerignore diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..409ec39f68192fb8e939d2d7ca0ac298a744cf95 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# HuggingFace requires ALL binary files to be stored via Git LFS. +*.pt filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.docx filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a2cf51401a7e739922aab23ab0187d4393b7ee61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Local data + secrets โ€” data/config.json holds Box credentials locally. +# On hosted deployments (HF Spaces) all of this comes from env-var secrets. +data/ +start.bat +start.local.bat +.env +.env.* + +# Logs / runtime +*.log +server.pid +*.stderr.log +*.stdout.log + +# Python +__pycache__/ +*.pyc + +# OS / editor +.DS_Store +Thumbs.db + +# Scratch +*.tmp +_*.png +_*.html diff --git a/BACKEND_SETUP.md b/BACKEND_SETUP.md new file mode 100644 index 0000000000000000000000000000000000000000..003b713b83ac4e2291dae7110d4c5284fcf309cd --- /dev/null +++ b/BACKEND_SETUP.md @@ -0,0 +1,95 @@ +# Backend Setup + +## What This Backend Does + +- Serves the app and API from one Node server +- Stores shared cases and workflow state in `data/cases.json` +- Accepts frontend case syncs and workflow actions +- Stores artifacts in Box while the backend/database stays the source of truth +- Optionally accepts legacy Box Automate updates at `POST /api/box/automate-update` + +## Start It + +Run: + +```bat +START_SERVER.bat +``` + +Preferred mode is Node.js. The server runs at: + +```txt +http://localhost:3030 +``` + +Health check: + +```txt +GET http://localhost:3030/api/health +``` + +## Optional Secret For Box Automate + +Set this before starting the server if you want Box callback protection: + +```powershell +$env:BOX_AUTOMATE_SECRET="replace-with-a-secret" +node server.js +``` + +Box Automate should then send the same value in: + +```txt +X-Box-Automate-Secret +``` + +or as: + +```txt +?secret=replace-with-a-secret +``` + +## Main API Endpoints + +- `GET /api/health` +- `GET /api/cases` +- `GET /api/cases/:caseId` +- `POST /api/cases/upsert` +- `POST /api/cases/bulk-upsert` +- `POST /api/cases/:caseId/patch` +- `POST /api/workflow/supervisor-decision` +- `POST /api/workflow/dispatch-plan` +- `POST /api/workflow/crew-closeout` +- `POST /api/workflow/resolve-case` +- `POST /api/box/automate-update` + +## Box Automate Payload Shape + +Send JSON like this from Box: + +```json +{ + "caseId": "PHL-20260530-ABCDE1", + "workflowStage": "crew_dispatch", + "workflowStatus": "Assigned", + "workflowPriority": "high", + "workflowNextAction": "Dispatch crew", + "folderId": "1234567890", + "boxPath": "Potholes/Ward-29/Case-PHL-20260530-ABCDE1", + "assignedTo": "dispatch@city.gov", + "resolutionNotes": "" +} +``` + +## What You Still Need From Box + +- A Box folder ID for the app root +- Supervisor email +- Dispatch or crew email/group +- A public URL for this backend when you want Box to call it from the cloud + +For local testing with Box cloud callbacks, use a tunnel such as `ngrok` or `cloudflared`, then point Box Automate `HTTPS Request` to: + +```txt +https://your-public-url/api/box/automate-update +``` diff --git a/CLIENT_DEMO_QUESTIONS.md b/CLIENT_DEMO_QUESTIONS.md new file mode 100644 index 0000000000000000000000000000000000000000..2bc5f76a8cc1f3c5fc21e283223e38ff82b6454b --- /dev/null +++ b/CLIENT_DEMO_QUESTIONS.md @@ -0,0 +1,73 @@ +# PotholeIQ โ€” Client Demo: Questions & Inputs to Collect + +Use this during the session. โญ = blocker (get it or we can't deploy). ๐Ÿ“ฅ = a file/credential to collect. โœ… = a decision to confirm. + +--- + +## 1. Box Access & Authentication โญ (blocks everything โ€” token expires every 60 min) +- โญ Who is your Box **enterprise admin**? Can they **authorize a custom app**? (needed for permanent auth) +- โœ… Auth method: **CCG** (admin authorizes once โ†’ permanent, recommended) or **OAuth** (one user login โ†’ 60-day)? +- ๐Ÿ“ฅ Box **Enterprise ID** + the **folder** to use (intake folder, case-root folder). +- โœ… Which Box features are **enabled/licensed**: Relay (Automate), **Sign**, **Doc Gen**, **Forms**, **Apps** (custom dashboards)? +- ๐Ÿ“ฅ A **service/bot Box account** to run the integration as (so it's not tied to a person)? + +## 2. City, GIS & Department +- โœ… Confirm **city / jurisdiction** (demo = Philadelphia). +- โญ๐Ÿ“ฅ GIS boundary files for **districts** and **maintenance/service zones** โ€” not just wards. Format? (GeoJSON / Shapefile). *(Today we only have ward polygons and derive the rest.)* +- ๐Ÿ“ฅ Official **ward โ†’ district โ†’ maintenance-zone** mapping, if it exists. +- ๐Ÿ“ฅ **Department name + logo / letterhead** for documents (work order, review sheet, closeout). + +## 3. Roles, People & Crew Dispatch +- ๐Ÿ“ฅ **Reviewers / district engineers / supervisors** โ€” names + **Box account emails**. +- โœ… Who **approves** a case, and who **dispatches** the crew? +- โœ… How is the **crew chosen per pothole**? (we built: reviewer types the crew email at approval โ€” confirm) +- โœ… Are **crews Box users**, or external (email only โ€” affects Sign vs notifications)? +- โœ… Who gets **notified** at each stage (new case / approved / dispatched / resolved)? + +## 4. Metadata Template & Fields +- โœ… Confirm the **case fields + dropdown values**. Known quirks to fix or keep: + - `weatherRisk` = **Low / Medium / Large** (no "High" โ€” typo?) + - `duplicateStatus` = `new / "duplicate " / pending` (**trailing space** on "duplicate") + - `status` includes **`In progress`** (odd capitalization) +- โœ… Any **additional fields** they track today (asset ID, 311 reference, etc.)? + +## 5. Severity Scoring, SLA & Cost +- โœ… Agree with the **severity factors** (prior reports, traffic/road class, damage/size, weather) and **weights**? +- โœ… **SLA targets** by severity (e.g. Critical = N days)? โ†’ drives "target completion" + days-to-resolve. +- ๐Ÿ“ฅ **Actual repair-cost** figures by size? (we use researched estimates: Small ~$90โ€“180, Medium ~$250โ€“450, Large ~$450โ€“800). + +## 6. Forms & Documents +- โœ… Confirm **citizen intake** fields and **crew closeout** form fields (current closeout: Case ID, Repair completed?, Method, Materials, Labor hours, After photo, Crew name, Crew contact). +- โœ… Confirm **Work Order / Review Sheet / Closeout** layouts โ€” fields to add/remove? +- โœ… **Box Sign** โ€” how many signatures and who? (we set crew + supervisor). + +## 7. Duplicates, Data & Volume +- โœ… **Duplicate radius** (we use 80 m) โ€” confirm. +- โœ… OK with an **external DB** (Supabase / PostGIS) for fast geo/duplicate search, or **all-in-Box** only? +- ๐Ÿ“ฅ **Historical case data** to import? +- โœ… Expected **volume** (reports/day) โ€” for sizing. + +## 8. AI / Detection +- โœ… Is the **open-source YOLO** pothole detector + size classification acceptable, or a specific accuracy bar / different model? +- โœ… Size thresholds (Small/Medium/Large) โ€” tune to their definition? + +## 9. Deployment & Infra +- โญ Where will the **backend + portal + dashboard** be hosted? **Domain/URL**? +- โญ **HTTPS + public endpoint** for Box callbacks/forms โ€” who provides it? +- โœ… Weather: use **our OpenWeather key** or **theirs** (their account/limits)? +- โœ… Who can access the **supervisor dashboard** (login list)? + +## 10. The Big Decision โ€” Architecture +- โœ… Which model do they want? + 1. **Fully inside Box** (Relay + Sign + Doc Gen + Apps) + 2. **Box as storage only** (our portal + backend do the logic) + 3. **Hybrid (recommended)** โ€” backend does geo/scoring/duplicate/weather + AI; Box does content, workflow, docs, sign, dashboard, governance + +--- + +### Fast "must-leave-with" list (the 5 that unblock everything) +1. โญ Admin to authorize the app (permanent auth) โ€” or schedule it. +2. โญ District + maintenance-zone GIS data (or confirm we derive from ward). +3. โญ Hosting + HTTPS endpoint owner. +4. ๐Ÿ“ฅ Reviewer/supervisor Box emails + department name/logo. +5. โœ… Architecture option (1 / 2 / 3). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..595476fcab8aff6c1e440cf0d00eff01e2b8a427 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# PotholeIQ โ€” Node backend + Python YOLO classifier + Chromium PDF rendering. +# Built for HuggingFace Spaces (Docker SDK, app_port 7860) but runs anywhere. +FROM node:20-slim + +# System deps: +# - python3 + pip -> YOLOv8 classifier (ai/pothole_server.py) + docx generation +# - chromium -> before/after PDF rendering (htmlToPdf) +# - libgl1 / libglib2.0 -> OpenCV runtime used by ultralytics +# - fonts-liberation -> readable PDF text +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip \ + chromium fonts-liberation \ + libgl1 libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# Python deps. CPU-only torch first (much smaller than the default CUDA build). +COPY requirements.txt /tmp/requirements.txt +RUN pip3 install --no-cache-dir --break-system-packages \ + torch torchvision --index-url https://download.pytorch.org/whl/cpu \ + && pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt + +WORKDIR /app + +# Node deps (cached layer) +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +# App code (see .dockerignore for exclusions โ€” no secrets, no local data) +COPY . . + +# Runtime env. PORT 7860 is what HuggingFace Spaces routes to. +ENV PORT=7860 \ + HOST=0.0.0.0 \ + PYTHON_BIN=python3 \ + CHROME_PATH=/usr/bin/chromium \ + YOLO_CONFIG_DIR=/tmp/ultralytics + +# HF Spaces runs the container as an arbitrary non-root user; make the app dir +# writable for the ephemeral data store (data/cases.json etc.). +RUN mkdir -p /app/data && chmod -R 777 /app/data && chmod 1777 /tmp + +EXPOSE 7860 +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..2250dafd18945fa577c2771f04e6a10776ed8bee --- /dev/null +++ b/LICENSE @@ -0,0 +1,31 @@ +CONFIDENTIAL & PROPRIETARY LICENSE AGREEMENT + +Copyright (c) 2026 Varun. All rights reserved. + +NOTICE: All information contained herein is, and remains the property of the Copyright Holder (Varun). The intellectual and technical concepts contained herein are proprietary to the Copyright Holder and may be covered by patents, patents in process, and are protected by trade secret or copyright law. + +Dissemination of this information or reproduction of this material is strictly forbidden unless prior written permission is obtained from the Copyright Holder. + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION: + +1. DEFINITIONS: + "Software" shall mean the Citizen Pothole Submission Wizard, Pothole Citizen Portal, and all associated files, source code, scripts, HTML, CSS, assets, and documentation located in this repository. + "Licensor" or "Copyright Holder" shall mean Varun, the sole owner and author of the Software. + "You" (or "Your") shall mean any individual or Legal Entity exercising permissions granted by this License. + +2. OWNERSHIP AND INTELLECTUAL PROPERTY: + The Software is licensed, not sold. All rights, title, and interest in and to the Software, including but not limited to all copyrights, trade secrets, patents, trademarks, and other intellectual property rights, are and shall remain the exclusive property of the Licensor. + +3. RESTRICTIONS: + You may NOT: + a. Copy, reproduce, redistribute, republish, upload, post, transmit, or distribute the Software in any way, in whole or in part, without explicit, prior written authorization from the Licensor. + b. Modify, translate, adapt, merge, make derivative works of, disassemble, decompile, decrypt, or reverse engineer any part of the Software. + c. Remove, alter, or obscure any copyright, trademark, or other proprietary rights notices contained in or on the Software. + d. Sublicense, rent, lease, lend, sell, or distribute the Software or any portion thereof to any third party. + e. Use the Software for any unauthorized, unlawful, or competitive commercial purpose. + +4. NO WARRANTY: + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +5. TERMINATION: + This License and Your right to use the Software shall terminate automatically and immediately if You fail to comply with any of its terms. Upon termination, You must immediately cease all use of the Software and destroy all copies of the Software in Your possession. diff --git a/Pothole Case Review Sheet (DocGen).docx b/Pothole Case Review Sheet (DocGen).docx new file mode 100644 index 0000000000000000000000000000000000000000..f8b7600fbb8fac8c6b882d431f36309ad8eeae83 --- /dev/null +++ b/Pothole Case Review Sheet (DocGen).docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2eae884c5096fb92b1d34307234a69c19a00574737620161f8f4d5da9e43c75 +size 37165 diff --git a/Pothole Closeout Job Sheet (DocGen Template).docx b/Pothole Closeout Job Sheet (DocGen Template).docx new file mode 100644 index 0000000000000000000000000000000000000000..7c95b194e288b6536c6a47b0f2f719cca97a1d29 --- /dev/null +++ b/Pothole Closeout Job Sheet (DocGen Template).docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a0710f1d930941db1d3829ba41d0e921e00e9b7c852d35d5f7f5f56de182427 +size 37725 diff --git a/Pothole Closeout Job Sheet (DocGen).docx b/Pothole Closeout Job Sheet (DocGen).docx new file mode 100644 index 0000000000000000000000000000000000000000..19e5754d2e7e3aa53c78194bbefc142d228f81a1 --- /dev/null +++ b/Pothole Closeout Job Sheet (DocGen).docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18dda542dafb4a50b2ed2f35b3759e4a49ee7ec8495379ecc7008581db397a12 +size 17378 diff --git a/Pothole Closeout Job Sheet SAMPLE.docx b/Pothole Closeout Job Sheet SAMPLE.docx new file mode 100644 index 0000000000000000000000000000000000000000..c6cf5701410fe0f3bc956e5a300c4fd67017288e --- /dev/null +++ b/Pothole Closeout Job Sheet SAMPLE.docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e08e6fba69dba8d55e447cab92f8e2f1b8719076ea798b03b14e0da23af458f +size 165704 diff --git a/Pothole Repair Work Order (DocGen).docx b/Pothole Repair Work Order (DocGen).docx new file mode 100644 index 0000000000000000000000000000000000000000..136912ae06b3b43377ba54561b93c5da10f0f676 --- /dev/null +++ b/Pothole Repair Work Order (DocGen).docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:624994a7611c4f44265b3c1b3f11ead562a97f6c5c2ba5eb728a0e3b8bce0d90 +size 17356 diff --git a/Pothole Repair Work Order.docx b/Pothole Repair Work Order.docx new file mode 100644 index 0000000000000000000000000000000000000000..7ca2f5317d640c537c32e476d3c0cfc35a689eca --- /dev/null +++ b/Pothole Repair Work Order.docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:111c6b2732a42a2a855360391ffde13ba8db380e20202dffa74ad1ff7c2b77b1 +size 17188 diff --git a/Pothole Work Order (DocGen Template).docx b/Pothole Work Order (DocGen Template).docx new file mode 100644 index 0000000000000000000000000000000000000000..aeea60f06a6dd33e906988202936834603914ae2 --- /dev/null +++ b/Pothole Work Order (DocGen Template).docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46b6ee4a88ad6ba10f5bd7238fe653987fc457a9c8451a85db019b1fcb711389 +size 39063 diff --git a/Pothole Work Order SAMPLE.docx b/Pothole Work Order SAMPLE.docx new file mode 100644 index 0000000000000000000000000000000000000000..0cc92e863cf3f250425c6363787c1b7bf502c46f --- /dev/null +++ b/Pothole Work Order SAMPLE.docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82c7a27628716fc0e93f01260e8223c1c020d82cf5fb8e798f4ac9f8c2010965 +size 195767 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b833982aa9053c861f7ebeaf07ee836547657dcd --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +--- +title: PotholeIQ +emoji: ๐Ÿ›ฃ๏ธ +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 7860 +pinned: false +--- + +# PotholeIQ โ€” Pothole Management on the Box Platform + +One workflow from citizen intake to verified repair: AI photo classification +(YOLOv8), severity scoring, GIS mapping, supervisor review, crew dispatch, +digital closeout, and an auditable **before/after report** โ€” with **Box** as the +content system of record and **Supabase** as the data mirror. + +## Pages + +| Page | Purpose | +|---|---| +| `/index.html` | Citizen submission portal (photo + GPS, AI classification) | +| `/command-center.html` | Supervisor Command Center (KPIs, map, approve/dispatch/resolve) | +| `/crew-closeout.html?case=ID` | Standalone crew closeout form (locked to one case) | +| `/case-report.html?case=ID` | Printable before/after report | +| `/track.html` | Citizen case tracking | + +## Configuration (Space secrets โ†’ env vars) + +**Box (required)** + +| Secret | Example / note | +|---|---| +| `BOX_AUTH_MODE` | `client_credentials` | +| `BOX_CLIENT_ID` | Box app client id | +| `BOX_CLIENT_SECRET` | Box app client secret | +| `BOX_SUBJECT_TYPE` | `enterprise` | +| `BOX_SUBJECT_ID` | Box enterprise id | +| `BOX_INTAKE_FOLDER_ID` | Intake folder id | +| `BOX_CASE_ROOT_FOLDER_ID` | Case root folder id (often same as intake) | +| `BOX_METADATA_SCOPE` | `enterprise` | +| `BOX_METADATA_TEMPLATE_KEY` | `potholeCase` | + +**Supabase (recommended โ€” data mirror + supervisor login)** + +| Secret | Note | +|---|---| +| `SUPABASE_URL` | `https://.supabase.co` | +| `SUPABASE_SECRET_KEY` | service-role key (server-side only) | +| `DASHBOARD_AUTH` | `true` to require supervisor login on dashboard APIs | + +**Optional** + +| Secret | Note | +|---|---| +| `CLOSEOUT_NOTIFY_RECIPIENTS` | Comma-separated default recipients for before/after reports | +| `BOX_AUTOMATE_SECRET` | Shared secret for Box Automate โ†’ `/api/box/automate-update` | +| `DATA_DIR` | Set to `/data/potholeiq` if HF persistent storage is enabled | +| `BOX_CASES_TTL_MS` | Box case-list cache TTL (default 45000) | + +## Architecture notes + +- **Box = source of truth for content**: photos, sidecars, generated documents, + before/after PDFs (in `Closeout Reports`), metadata (`potholeCase` template). +- **Supabase = data mirror** (`db/schema.sql`, run once in the Supabase SQL editor). +- The local `data/` store is a working cache only โ€” safe to lose on restarts. +- First AI classification after a cold start takes ~30โ€“60 s (model load). diff --git a/START_SERVER.bat b/START_SERVER.bat new file mode 100644 index 0000000000000000000000000000000000000000..5190901222e6735631fca1f6c2c36f375b458e3a --- /dev/null +++ b/START_SERVER.bat @@ -0,0 +1,60 @@ +@echo off +title Pothole Portal - Local Server +color 0A +echo. +echo ============================================ +echo POTHOLE CITIZEN PORTAL - LOCAL SERVER +echo ============================================ +echo. +echo Starting server on port 3030... +echo. + +REM Prefer the Node backend because it serves both the app and the API +node --version >nul 2>&1 +if %errorlevel% == 0 ( + echo [Node.js] Full app + API server running at: + echo. + echo http://localhost:3030 + echo. + echo This includes the Box workflow backend endpoints. + echo Press Ctrl+C to stop. + echo. + node server.js + goto end +) + +REM Fallback to Python static hosting if Node is unavailable +python --version >nul 2>&1 +if %errorlevel% == 0 ( + echo [Python] Static server running at: + echo. + echo http://localhost:3030 + echo. + echo WARNING: API backend endpoints will NOT be available in this mode. + echo. + python -m http.server 3030 + goto end +) + +REM Try Python3 +python3 --version >nul 2>&1 +if %errorlevel% == 0 ( + echo [Python3] Static server running at: + echo. + echo http://localhost:3030 + echo. + echo WARNING: API backend endpoints will NOT be available in this mode. + echo. + python3 -m http.server 3030 + goto end +) + +echo ERROR: Neither Node.js nor Python was found. +echo. +echo Please install Node.js from https://nodejs.org +echo or Python from https://python.org +echo then run this file again. +echo. + +:end +pause diff --git a/WORKFLOW_STORAGE_ONLY_GUIDE.md b/WORKFLOW_STORAGE_ONLY_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..910f535884a59128426dcc852e26ee2a99545d7e --- /dev/null +++ b/WORKFLOW_STORAGE_ONLY_GUIDE.md @@ -0,0 +1,260 @@ +# Pothole Workflow: Box Storage Only + +This is the recommended workflow if Box is used only as the shared content repository. + +In this model: + +- Box stores photos, JSON files, case folders, review summaries, closeout proof, and reports +- The app and backend handle review, approvals, dispatch, authority notification, crew forms, and reporting logic +- GIS, weather, duplicate checks, and severity scoring all happen outside Box + +## 1. Architecture + +Use this split everywhere: + +- `Box` + - intake photo storage + - intake JSON storage + - organized case folders + - enriched JSON files + - human-readable review summaries + - after-photos and repair proof + - generated reports + +- `Backend / app` + - GIS mapping + - reverse geocoding + - duplicate detection + - weather enrichment + - severity scoring + - supervisor review decisions + - authority email + - crew assignment and routing + - crew completion form + - before/after report generation + +## 2. End-to-End Flow + +### Stage 1. Citizen Intake + +Owner: +- citizen portal + +System actions: +- collect photo +- collect GPS or manual pin +- reverse geocode address +- capture AI review status and pothole size +- upload photo to Box `Incoming Detections` +- upload JSON sidecar to Box `Incoming Detections` +- upsert the same case into the backend + +Status: +- `Submitted` + +Main files: +- `index.html` +- `box-upload.js` +- `box-service.js` + +### Stage 2. Ops Enrichment + +Owner: +- ops review console + +System actions: +- load submitted case from backend +- resolve official GIS ward, district, and maintenance zone +- check duplicates by GPS radius +- pull weather risk +- calculate severity score and severity level +- organize the case into the right Box folder +- write enriched JSON into the case folder +- write a readable review summary into the case folder +- write Box metadata for reporting and indexing + +Status: +- `Under Review` + +Main files: +- `ops-review.html` +- `enrichment.js` +- `gis.js` +- `box-folders.js` + +### Stage 3. Supervisor Review + +Owner: +- supervisor using our app, not Box + +System actions: +- review image +- review readable summary +- review severity, duplicate status, weather, ward, district, and zone +- choose: + - approve + - reject + - merge duplicate + +Status: +- `Under Review` until approved or rejected + +Suggested result fields: +- `supervisorDecision` +- `supervisorReason` +- `supervisorAt` +- `supervisorBy` + +### Stage 4. Authority Notification + +Owner: +- backend + +System actions: +- only for approved primary cases +- look up authority by district or maintenance zone +- send authority email with case details +- include Box folder path or Box link + +Status: +- stays `Under Review` until dispatch is created + +### Stage 5. Work Order and Dispatch + +Owner: +- dispatcher or backend + +System actions: +- create work order outside Box +- assign crew +- generate route plan +- save dispatch artifact to Box +- patch backend case with crew assignment + +Status: +- `Assigned` + +Main files: +- `deploy-crew.html` + +### Stage 6. Crew Work Start + +Owner: +- field crew + +System actions: +- crew accepts assignment +- mark start time +- mark assigned crew and route stop + +Status: +- `In Progress` + +### Stage 7. Crew Completion + +Owner: +- field crew + +System actions: +- submit after-photo +- submit materials used +- submit labor hours +- submit repair notes +- submit signature if required +- upload closeout files to Box + +Status: +- still `In Progress` until verified + +Suggested fields: +- `afterPhotoFileId` +- `materialsUsed` +- `laborHours` +- `resolutionNotes` +- `crewSignature` +- `completedAt` + +### Stage 8. Supervisor Closeout + +Owner: +- supervisor + +System actions: +- verify repair proof +- verify after-photo +- mark case resolved +- optionally notify citizen + +Status: +- `Resolved` + +### Stage 9. Reporting + +Owner: +- backend + +System actions: +- generate before/after report +- generate daily and weekly summaries +- upload reports to Box `Reports` + +Outputs: +- per-case audit report +- daily summary report +- weekly summary report + +## 3. Box Folder Responsibilities + +Use Box for these artifacts only: + +- original photo +- original JSON sidecar +- enriched JSON +- readable review summary +- before and after photos +- work order file +- dispatch plan export +- audit report +- daily and weekly reports + +## 4. JSON Fields To Keep Standard + +These are the business fields we should keep stable: + +- `caseId` +- `submittedAt` +- `address` +- `areaLabel` +- `location.lat` +- `location.lng` +- `location.source` +- `ward` +- `district` +- `maintenanceZone` +- `duplicateStatus` +- `weatherStatus` +- `potholeSize` +- `aiResult` +- `aiPrediction` +- `aiReviewStatus` +- `aiReviewMessage` + +## 5. Current Best Operating Order + +1. Citizen submits report +2. Box stores photo and intake JSON +3. Ops review enriches the case outside Box +4. Box stores the enriched JSON and review summary +5. Supervisor reviews in our app +6. Backend sends authority notification +7. Dispatcher assigns crew and route +8. Crew uploads closeout proof +9. Backend generates reports into Box + +## 6. Immediate Next Build Order + +1. Supervisor decision outside Box +2. Authority email automation outside Box +3. Crew completion form outside Box +4. Before/after report generator outside Box +5. Save all artifacts back into Box diff --git a/WORKFLOW_V2_GUIDE.md b/WORKFLOW_V2_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..ebc04d1f946828ee84796ceae6710c5558b8599e --- /dev/null +++ b/WORKFLOW_V2_GUIDE.md @@ -0,0 +1,434 @@ +# Pothole Operations Workflow V2 + +This is the recommended operating workflow for the current `pothole box` app. + +It is designed around the code that already exists: + +- Citizen intake: `index.html`, `app.js`, `box-upload.js` +- Ops review and enrichment: `ops-review.html`, `enrichment.js`, `box-folders.js` +- Shared case state: `server.js`, `backend-api.js` +- Crew dispatch: `deploy-crew.html` +- Citizen tracking: `track.html` +- GIS routing: `gis.js` +- Weather enrichment: `enrichment.js` + +## 1. Core Rules + +Use these rules everywhere. + +1. Keep only these public case statuses: + `Submitted`, `Under Review`, `Assigned`, `In Progress`, `Resolved`, `Rejected` + +2. Box is the file and metadata system: + photos, JSON sidecars, case folders, enriched files, dispatch artifacts, completion proof + +3. The backend is the shared live case state: + every screen should read and write through the backend, not only `localStorage` + +4. GIS, weather, AI, and duplicate detection are decision inputs: + they help routing and prioritization, but they do not auto-dispatch by themselves + +5. Authority email must not be sent on raw citizen submission: + send it only after supervisor approval and duplicate review + +6. Duplicate child cases must not create extra crew jobs: + one pothole cluster should have one active operational case owner + +7. A case is not truly closed until repair proof exists: + after-photo, crew notes, completion timestamp, and supervisor confirmation + +## 2. Recommended End-to-End Workflow + +### Stage 1. Citizen Submission + +Owner: citizen portal + +Trigger: +- User submits photo, location, and optional contact + +System actions: +- Run image and road checks +- Capture GPS and reverse geocode +- Resolve GIS mapping if configured +- Upload photo and metadata sidecar to Box +- Create initial workflow metadata +- Upsert case to backend + +Status after this stage: +- `Submitted` + +Files involved: +- `index.html` +- `app.js` +- `box-upload.js` + +### Stage 2. Ops Enrichment + +Owner: ops review console + +Trigger: +- New `Submitted` case appears in ops queue + +System actions: +- Run severity enrichment +- Check duplicates by GPS radius +- Pull weather risk +- Resolve district, ward, and maintenance zone +- Organize the case into the correct Box folder +- Write enriched metadata back to Box and backend + +Status after this stage: +- `Under Review` + +Files involved: +- `ops-review.html` +- `enrichment.js` +- `box-folders.js` +- `gis.js` + +### Stage 3. Supervisor Decision + +Owner: supervisor + +Supervisor can choose one of three outcomes: + +1. Reject +- Use when the report is invalid, unsafe, or not a pothole +- Final status: `Rejected` + +2. Merge into an existing active case +- Use when duplicate detection is confirmed +- Do not create a new crew assignment +- Do not send a new authority email +- Keep one primary operational case for the cluster +- Child case should stay linked to the parent in metadata + +3. Approve for action +- Use when it is a valid pothole and should move to operations +- Continue to authority notification and dispatch + +Status after approval: +- Keep `Under Review` until authority notification and dispatch preparation are done + +Files involved: +- `ops-review.html` + +### Stage 4. Authority Notification + +Owner: backend automation + +Trigger: +- Supervisor approved the case +- Case is the primary case in its duplicate cluster +- District and maintenance zone are known + +Notification policy: + +1. Critical severity +- Send immediately to the concerned authority and dispatch lead + +2. High, Medium, Low severity +- Send in district or zone digest batches every 30 to 60 minutes + +Email must include: +- Case ID +- Address +- GIS district, ward, maintenance zone +- Severity score and level +- Duplicate count and linked cases +- Reporter count if relevant +- Box folder path or Box folder link +- Recommended next action + +Important guardrail: +- Do not send one email per duplicate report + +Status after this stage: +- Still `Under Review` until a crew is actually assigned + +Build needed: +- Add backend email service and routing table + +### Stage 5. Crew Dispatch + +Owner: dispatcher or supervisor + +Trigger: +- Approved primary case is ready for field work + +System actions: +- Group approved active potholes by ward or zone +- Generate route plan +- Assign crew +- Save dispatch plan +- Push assignment to backend + +Status after this stage: +- `Assigned` + +Files involved: +- `deploy-crew.html` + +### Stage 6. Crew Work Start + +Owner: field crew + +Trigger: +- Crew accepts the assignment or starts work on site + +System actions: +- Mark case start time +- Mark assigned crew +- Optionally capture ETA or on-site note + +Status after this stage: +- `In Progress` + +Build needed: +- Crew action form or simple crew mobile screen + +### Stage 7. Crew Completion Form + +Owner: field crew + +Required fields: +- Case ID +- Crew name +- Completion timestamp +- After-photo +- Repair notes +- Material used or fill quantity +- Could not complete reason, if blocked + +System actions: +- Upload completion proof to Box +- Patch backend case +- Flag case for supervisor closeout + +Status after this stage: +- Keep `In Progress` until supervisor verifies proof + +Build needed: +- New crew completion form page tied to Box and backend + +### Stage 8. Supervisor Closeout + +Owner: supervisor + +Trigger: +- Crew completion proof received + +System actions: +- Review after-photo and notes +- Confirm repair is complete +- Mark resolved timestamp +- Optionally notify citizen if contact was provided + +Status after this stage: +- `Resolved` + +### Stage 9. Reporting + +Owner: backend automation + +Trigger: +- Scheduled daily and weekly jobs + +Reports to generate: +- New cases by district and ward +- Duplicate clusters +- Average approval time +- Average assignment time +- Average repair completion time +- Open active cases by zone +- Rejected reports +- Weather-linked spikes +- Crew productivity summary + +Output: +- Save CSV or PDF report into a Box reports folder +- Email summary to management if needed + +Build needed: +- Scheduled report generator on backend + +## 3. Status Rules + +Use this as the single status policy. + +### `Submitted` + +Meaning: +- Citizen report received +- Photo and metadata saved +- Waiting for ops enrichment + +### `Under Review` + +Meaning: +- Ops is validating the report +- Duplicate and routing checks are in progress +- Supervisor approval is pending or just completed but dispatch is not done yet + +### `Assigned` + +Meaning: +- A crew has been assigned +- Dispatch plan exists + +### `In Progress` + +Meaning: +- Crew is actively working or has started the job + +### `Resolved` + +Meaning: +- Repair proof exists +- Supervisor or workflow confirmed the fix + +### `Rejected` + +Meaning: +- The report is invalid or closed without field action + +## 4. What Should Live Where + +### Keep in Box + +- Original photo +- Original metadata sidecar +- Enriched metadata file +- Case folder metadata +- Dispatch sheet +- After-photo and repair proof +- Final reports + +### Keep in Backend + +- Current status +- Current assignee +- Workflow timestamps +- Parent and child duplicate links +- Authority email audit trail +- Crew start and completion data + +### Keep as Integrations + +- GIS boundary lookup +- Weather forecast lookup +- Email delivery + +## 5. Safe Rollout Order + +This is the best order to implement without breaking the app. + +### Step 1. Stabilize shared case state + +Do first. + +Goal: +- Make the backend the live source of truth across intake, ops review, dispatch, and tracking + +Why: +- Right now some workflow still depends on `localStorage`, which is risky for multi-user operations + +### Step 2. Lock the status model + +Do second. + +Goal: +- Use only the six public statuses already supported by the tracking UI + +Why: +- Avoid adding new public statuses until the tracking and review UIs are updated together + +### Step 3. Add authority routing table + +Do third. + +Goal: +- Map each district or maintenance zone to the correct authority email list + +Needed fields: +- `district` +- `ward` +- `maintenanceZone` +- `authorityEmails` +- `dispatchEmails` + +### Step 4. Add real authority email automation + +Do fourth. + +Goal: +- Send approved active pothole notifications from the backend + +Rules: +- Immediate for `Critical` +- Batched digest for the rest +- No email for duplicate child cases + +### Step 5. Add crew completion form + +Do fifth. + +Goal: +- Give crews a proper way to upload after-photos and repair notes + +### Step 6. Add closeout verification + +Do sixth. + +Goal: +- Resolve only after proof is reviewed + +### Step 7. Add scheduled reporting + +Do seventh. + +Goal: +- Auto-generate daily and weekly reports into Box + +### Step 8. Move secrets off the browser + +Do this before production use. + +Goal: +- Remove Box and weather secrets from browser storage +- Keep them on the backend instead + +## 6. Immediate Next Recommendation + +If you want the cleanest next build step, do this first: + +1. Make backend case sync the source of truth +2. Add authority email automation on approved primary cases +3. Add crew completion form +4. Add daily and weekly report generator + +That order gives you the biggest operational improvement with the least confusion. + +## 7. Short Version + +The correct workflow is: + +1. Citizen submits report +2. Ops enriches and checks duplicates +3. Supervisor rejects, merges, or approves +4. Backend notifies the correct authority +5. Dispatcher assigns crew +6. Crew marks work in progress +7. Crew submits completion proof +8. Supervisor closes the case +9. Backend generates reports + +The most important correction is this: + +Do not treat Box alone as the whole system. + +Use: +- Box for files and workflow metadata +- Backend for live state and automation +- GIS and weather as decision inputs +- Email and reports as backend-driven automations diff --git a/add-supervisor.js b/add-supervisor.js new file mode 100644 index 0000000000000000000000000000000000000000..de11b9c50d6670f3ca70af9ed69d7c64a50f621e --- /dev/null +++ b/add-supervisor.js @@ -0,0 +1,38 @@ +/** + * Add (or update) a supervisor who can access the Command Center. + * + * Usage: + * SUPABASE_URL=... SUPABASE_SECRET_KEY=sb_secret_... \ + * node add-supervisor.js + * + * Creates an auto-confirmed Supabase user with app_metadata.role = "supervisor" + * (the role the backend + login page require). Re-running for an existing email + * resets the password and ensures the role. + */ +'use strict'; +const { createClient } = require('@supabase/supabase-js'); + +const URL = process.env.SUPABASE_URL; +const SECRET = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_KEY; +const [email, password] = process.argv.slice(2); + +if (!URL || !SECRET) { console.error('Set SUPABASE_URL and SUPABASE_SECRET_KEY env vars.'); process.exit(1); } +if (!email || !password) { console.error('Usage: node add-supervisor.js '); process.exit(1); } + +(async () => { + const admin = createClient(URL, SECRET, { auth: { persistSession: false } }); + const { error } = await admin.auth.admin.createUser({ + email, password, email_confirm: true, app_metadata: { role: 'supervisor' }, + }); + if (error && /already/i.test(error.message)) { + const list = await admin.auth.admin.listUsers(); + const u = list.data.users.find((x) => x.email === email); + if (!u) { console.error('User exists but could not be located.'); process.exit(1); } + await admin.auth.admin.updateUserById(u.id, { password, app_metadata: { role: 'supervisor' } }); + console.log('Updated supervisor:', email); + } else if (error) { + console.error('Error:', error.message); process.exit(1); + } else { + console.log('Created supervisor:', email); + } +})().catch((e) => { console.error('ERROR', e.message); process.exit(1); }); diff --git a/ai/detect_pothole.py b/ai/detect_pothole.py new file mode 100644 index 0000000000000000000000000000000000000000..529456c09305292cf203d9a73a4e8fdf719ddbc8 --- /dev/null +++ b/ai/detect_pothole.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +""" +Open-source pothole classifier โ€” YOLOv8 (Ultralytics), runs locally, no API cost. +Model: peterhdd/pothole-detection-yolov8 (single "pothole" class). + +Usage: python ai/detect_pothole.py +Output: one line of JSON on stdout: + {"isPothole": bool, "confidence": float, "size": "Small|Medium|Large"|null, + "count": int, "largestAreaFraction": float, "boxes": [...], "model": "..."} +Errors are also returned as JSON: {"error": "..."}. +""" +import sys +import os +import json + +# Size thresholds: largest detection box area as a fraction of the whole frame. +# A single 2D photo has no true scale, so this is a heuristic proxy (tune as needed). +SIZE_LARGE = 0.12 +SIZE_MEDIUM = 0.04 +CONF_THRESHOLD = 0.25 +MODEL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pothole-yolov8.pt") + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({"error": "usage: detect_pothole.py "})) + return + image_path = sys.argv[1] + if not os.path.exists(image_path): + print(json.dumps({"error": "image not found: " + image_path})) + return + if not os.path.exists(MODEL_FILE): + print(json.dumps({"error": "model file missing: " + MODEL_FILE})) + return + try: + from ultralytics import YOLO + except Exception as e: # noqa: BLE001 + print(json.dumps({"error": "ultralytics not installed: " + str(e)})) + return + + try: + model = YOLO(MODEL_FILE) + result = model.predict(image_path, conf=CONF_THRESHOLD, verbose=False)[0] + h, w = result.orig_shape + frame_area = float(h * w) if h and w else 1.0 + + boxes = [] + max_frac = 0.0 + max_conf = 0.0 + for b in result.boxes: + x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist()) + conf = float(b.conf[0]) + frac = ((x2 - x1) * (y2 - y1)) / frame_area + boxes.append({ + "x1": round(x1), "y1": round(y1), "x2": round(x2), "y2": round(y2), + "conf": round(conf, 3), "areaFraction": round(frac, 4), + }) + max_frac = max(max_frac, frac) + max_conf = max(max_conf, conf) + + is_pothole = len(boxes) > 0 + if not is_pothole: + size = None + elif max_frac >= SIZE_LARGE: + size = "Large" + elif max_frac >= SIZE_MEDIUM: + size = "Medium" + else: + size = "Small" + + print(json.dumps({ + "isPothole": is_pothole, + "confidence": round(max_conf, 3), + "size": size, + "count": len(boxes), + "largestAreaFraction": round(max_frac, 4), + "boxes": boxes, + "model": "peterhdd/pothole-detection-yolov8", + })) + except Exception as e: # noqa: BLE001 + print(json.dumps({"error": str(e)})) + + +if __name__ == "__main__": + main() diff --git a/ai/gen_closeout.py b/ai/gen_closeout.py new file mode 100644 index 0000000000000000000000000000000000000000..21a65998951c076b4f825bff07200f52d1592004 --- /dev/null +++ b/ai/gen_closeout.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +"""Generate a clean Pothole Repair CLOSEOUT / Job Completion Sheet (.docx). + +The post-repair record the crew completes (often via Box Forms): what was done, +materials, hours, before/after photos, actual cost, inspection, sign-off. + +Usage: python gen_closeout.py +Works for live docs AND as a Box Doc Gen template when values are {{tags}}. +""" +import sys +import os +import json + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from docx import Document +from docx.shared import Pt, Inches, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_ALIGN_VERTICAL +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +# reuse the work order's polished styling helpers for a consistent look +from gen_workorder import ( + NAVY, GREY, LIGHT, DARK, WHITE, + shade, no_borders, grid, cell_pad, widths, run, spacer, section, info_grid, sign_block, +) + +GREEN = RGBColor(0x15, 0x7A, 0x3F) + + +def photo_row(doc, before, after): + """Two-column before/after photo row (embeds files; otherwise shows the link/placeholder).""" + t = doc.add_table(rows=2, cols=2) + no_borders(t) + cell_pad(t, top=20, bottom=20, left=20, right=80) + run(t.cell(0, 0).paragraphs[0], "BEFORE (reported)", size=7.5, bold=True, color=LIGHT, caps=True) + run(t.cell(0, 1).paragraphs[0], "AFTER (repaired)", size=7.5, bold=True, color=LIGHT, caps=True) + for col, val in ((0, before), (1, after)): + cell = t.cell(1, col) + p = cell.paragraphs[0] + if val and os.path.exists(str(val)): + try: + run(p, "") + p.add_run().add_picture(val, width=Inches(3.0)) + continue + except Exception: + pass + if val: + run(p, str(val), size=9, color=NAVY) + else: + run(p, "โ˜ photo attached", size=9.5, color=GREY) + widths(t, [3.45, 3.45]) + + +def main(): + if len(sys.argv) < 3: + print(json.dumps({"error": "usage: gen_closeout.py "})) + return + with open(sys.argv[1], "r", encoding="utf-8") as f: + s = json.load(f) + out_path = sys.argv[2] + g = lambda k, d="": s.get(k) if s.get(k) not in (None, "") else d # noqa: E731 + + doc = Document() + normal = doc.styles["Normal"] + normal.font.name = "Calibri" + normal.font.size = Pt(10) + normal.font.color.rgb = DARK + for sec in doc.sections: + sec.left_margin = sec.right_margin = Inches(0.75) + sec.top_margin = sec.bottom_margin = Inches(0.6) + + # โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + head = doc.add_table(rows=1, cols=2) + no_borders(head) + cell_pad(head, 0, 0, 0, 0) + L = head.cell(0, 0).paragraphs[0] + run(L, g("city", "City of Philadelphia"), size=9, bold=True, color=NAVY) + L.add_run("\n") + run(L, g("department", "Department of Streets ยท Road Maintenance Division"), size=8.5, color=GREY) + L.add_run("\n\n") + run(L, "Pothole Repair โ€” Closeout Job Sheet", size=18, bold=True, color=DARK) + R = head.cell(0, 1) + rp = R.paragraphs[0] + rp.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run(rp, "CASE ID", size=7.5, bold=True, color=LIGHT, caps=True) + rp2 = R.add_paragraph(); rp2.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run(rp2, g("caseId", "____________"), size=15, bold=True, color=NAVY) + rp4 = R.add_paragraph(); rp4.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run(rp4, "Status ", size=9, color=GREY) + run(rp4, "COMPLETED", size=9, bold=True, color=GREEN) + widths(head, [4.0, 3.0]) + + rule = doc.add_paragraph(); rule.paragraph_format.space_before = Pt(4); rule.paragraph_format.space_after = Pt(2) + pbdr = OxmlElement("w:pBdr"); b = OxmlElement("w:bottom") + b.set(qn("w:val"), "single"); b.set(qn("w:sz"), "18"); b.set(qn("w:color"), "0B3D91") + pbdr.append(b); rule._p.get_or_add_pPr().append(pbdr) + + # โ”€โ”€ Repair details (ONLY the crew closeout form fields) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section(doc, "Repair Details") + info_grid(doc, [ + ("Case ID", g("caseId")), + ("Repair Completed?", g("repairCompleted", "โ˜ Yes โ˜ No")), + ("Repair Method", g("repairMethods", "____________________")), + ("Materials Used", g("materialsUsed", "____________________")), + ("Labor Hours", g("laborHours", "________")), + ]) + + # โ”€โ”€ Crew (from the form) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section(doc, "Crew") + info_grid(doc, [ + ("Crew Name", g("crewName", "____________________")), + ("Crew Contact Number", g("crewContact", "____________________")), + ], cols=2) + + # โ”€โ”€ Reported photo (the citizen's pre-repair photo, so the crew sees what to fix) โ”€โ”€ + reported = s.get("reportedPhotoPath") + if reported and os.path.exists(str(reported)): + section(doc, "Reported Photo (Pre-Repair)") + try: + doc.add_picture(reported, width=Inches(4.2)) + except Exception: + pass + + # โ”€โ”€ After photo (from the form) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section(doc, "After Photo") + after = s.get("afterPhotoPath") or g("afterPhoto") + if after and os.path.exists(str(after)): + try: + doc.add_picture(after, width=Inches(4.2)) + except Exception: + run(doc.add_paragraph(), str(after), size=9.5, color=NAVY) + elif after: + run(doc.add_paragraph(), str(after), size=9.5, color=NAVY) + else: + run(doc.add_paragraph(), "โ˜ photo attached to closeout form", size=9.5, color=GREY) + + # โ”€โ”€ Sign-off โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section(doc, "Sign-Off") + sign_block(doc, ["Crew Foreman", "Supervisor"]) + + foot = doc.add_paragraph(); foot.paragraph_format.space_before = Pt(10) + run(foot, "Generated by PotholeIQ" + (" ยท " + g("completedDate") if g("completedDate") else ""), size=8, italic=True, color=LIGHT) + + doc.save(out_path) + print(json.dumps({"ok": True, "output": out_path})) + + +if __name__ == "__main__": + main() diff --git a/ai/gen_doc.py b/ai/gen_doc.py new file mode 100644 index 0000000000000000000000000000000000000000..d272c061295cc1cb4ba7c4142102303c18a9e823 --- /dev/null +++ b/ai/gen_doc.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python +"""Generate a styled .docx (Review Sheet or Work Order) with the pothole photo embedded. + +Box Doc Gen cannot reliably embed an image-from-URL, so we render the document here +with python-docx (the image embeds cleanly and Box previews .docx natively). + +Usage: python gen_doc.py + +spec.json: +{ + "title": "...", "subtitle": "...", + "meta": [["Label","Value"], ...], # small lines under the title (optional) + "sections": [ {"heading":"...", "fields":[["Label","Value"], ...]}, ... ], + "imagePath": "/abs/path/photo.jpg" | null, + "imageCaption": "..." | null, + "notes": "..." | null, + "accent": "0061D5" # hex, optional (Box blue default) +} +""" +import sys +import os +import json +from docx import Document +from docx.shared import Pt, Inches, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH + + +def hex_rgb(h, default=(0x00, 0x61, 0xD5)): + try: + h = (h or "").lstrip("#") + return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + except Exception: + return RGBColor(*default) + + +GREY = RGBColor(0x5B, 0x64, 0x76) +DARK = RGBColor(0x17, 0x20, 0x33) + + +def add_title(doc, title, subtitle, accent): + p = doc.add_paragraph() + r = p.add_run(title or "") + r.bold = True + r.font.size = Pt(22) + r.font.color.rgb = accent + if subtitle: + p2 = doc.add_paragraph() + r2 = p2.add_run(subtitle) + r2.font.size = Pt(11) + r2.font.color.rgb = GREY + + +def add_meta(doc, meta): + if not meta: + return + for label, value in meta: + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(0) + rl = p.add_run(f"{label}: ") + rl.bold = True + rl.font.size = Pt(9) + rl.font.color.rgb = GREY + rv = p.add_run("" if value is None else str(value)) + rv.font.size = Pt(9) + rv.font.color.rgb = DARK + + +def add_heading(doc, text, accent): + p = doc.add_paragraph() + p.paragraph_format.space_before = Pt(12) + r = p.add_run((text or "").upper()) + r.bold = True + r.font.size = Pt(11) + r.font.color.rgb = accent + + +def add_images_grid(doc, images, accent): + """Lay out a list of {path, caption} images. Two images -> side-by-side + before/after; otherwise stacked. Each gets its caption underneath.""" + valid = [im for im in images if im.get("path") and os.path.exists(im["path"])] + if not valid: + return + add_heading(doc, "Before / After Photos", accent) + if len(valid) == 2: + table = doc.add_table(rows=1, cols=2) + for cell, im in zip(table.rows[0].cells, valid): + p = cell.paragraphs[0] + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + try: + p.add_run().add_picture(im["path"], width=Inches(3.05)) + except Exception as e: # noqa: BLE001 + p.add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9) + cap = cell.add_paragraph() + cap.alignment = WD_ALIGN_PARAGRAPH.CENTER + rc = cap.add_run(im.get("caption") or "") + rc.bold = True + rc.font.size = Pt(9) + rc.font.color.rgb = GREY + return + for im in valid: + try: + doc.add_picture(im["path"], width=Inches(4.6)) + doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT + except Exception as e: # noqa: BLE001 + doc.add_paragraph().add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9) + cap = im.get("caption") + if cap: + pc = doc.add_paragraph() + rc = pc.add_run(cap) + rc.italic = True + rc.font.size = Pt(9) + rc.font.color.rgb = GREY + + +def add_kv_table(doc, fields): + table = doc.add_table(rows=0, cols=2) + table.style = "Light List Accent 1" + for label, value in fields: + cells = table.add_row().cells + rl = cells[0].paragraphs[0].add_run(str(label)) + rl.bold = True + rl.font.size = Pt(9) + rl.font.color.rgb = GREY + val = "โ€”" if value in (None, "") else str(value) + rv = cells[1].paragraphs[0].add_run(val) + rv.font.size = Pt(11) + rv.font.color.rgb = DARK + for row in table.rows: + row.cells[0].width = Inches(2.3) + row.cells[1].width = Inches(4.0) + + +def main(): + if len(sys.argv) < 3: + print(json.dumps({"error": "usage: gen_doc.py "})) + return + spec_path, out_path = sys.argv[1], sys.argv[2] + with open(spec_path, "r", encoding="utf-8") as f: + spec = json.load(f) + + accent = hex_rgb(spec.get("accent")) + doc = Document() + for s in doc.sections: + s.left_margin = s.right_margin = Inches(0.8) + s.top_margin = s.bottom_margin = Inches(0.7) + + add_title(doc, spec.get("title"), spec.get("subtitle"), accent) + add_meta(doc, spec.get("meta") or []) + + for section in spec.get("sections") or []: + add_heading(doc, section.get("heading"), accent) + add_kv_table(doc, section.get("fields") or []) + + # Multi-image (e.g. before/after) takes precedence over a single site photo. + images = spec.get("images") + if isinstance(images, list) and images: + add_images_grid(doc, images, accent) + + image_path = spec.get("imagePath") + if not (isinstance(images, list) and images) and image_path and os.path.exists(image_path): + add_heading(doc, "Site Photo", accent) + try: + doc.add_picture(image_path, width=Inches(4.6)) + doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT + cap = spec.get("imageCaption") + if cap: + pc = doc.add_paragraph() + rc = pc.add_run(cap) + rc.italic = True + rc.font.size = Pt(9) + rc.font.color.rgb = GREY + except Exception as e: # noqa: BLE001 + pe = doc.add_paragraph() + pe.add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9) + + notes = spec.get("notes") + if notes: + add_heading(doc, "Notes", accent) + doc.add_paragraph(str(notes)) + + doc.save(out_path) + print(json.dumps({"ok": True, "output": out_path})) + + +if __name__ == "__main__": + main() diff --git a/ai/gen_workorder.py b/ai/gen_workorder.py new file mode 100644 index 0000000000000000000000000000000000000000..93bb77db754844a0d6b231523809236aa10b3e26 --- /dev/null +++ b/ai/gen_workorder.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python +"""Generate a clean, professional municipal Pothole Repair WORK ORDER (.docx). + +Modeled on standard public-works work order forms. Known data is filled in; +crew-completed sections are blank lines / checkboxes. + +Usage: python gen_workorder.py +(Same spec keys as before โ€” works for live docs AND as a Box Doc Gen template +when values are passed as {{tags}}.) +""" +import sys +import os +import json +from docx import Document +from docx.shared import Pt, Inches, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_ALIGN_VERTICAL +from docx.oxml.ns import qn +from docx.oxml import OxmlElement + +NAVY = RGBColor(0x0B, 0x3D, 0x91) +GREY = RGBColor(0x6B, 0x72, 0x80) +LIGHT = RGBColor(0x9A, 0xA1, 0xAD) +DARK = RGBColor(0x1A, 0x1F, 0x2B) +WHITE = RGBColor(0xFF, 0xFF, 0xFF) + + +# โ”€โ”€ low-level helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +def shade(cell, hex_fill): + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), hex_fill) + tcPr.append(shd) + + +def no_borders(table): + borders = OxmlElement("w:tblBorders") + for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): + e = OxmlElement(f"w:{edge}") + e.set(qn("w:val"), "none") + borders.append(e) + table._tbl.tblPr.append(borders) + + +def hairlines(table, color="E3E7EE"): + """Subtle horizontal rules only โ€” clean ledger look.""" + borders = OxmlElement("w:tblBorders") + for edge in ("top", "left", "right", "insideV"): + e = OxmlElement(f"w:{edge}") + e.set(qn("w:val"), "none") + borders.append(e) + for edge in ("bottom", "insideH"): + e = OxmlElement(f"w:{edge}") + e.set(qn("w:val"), "single") + e.set(qn("w:sz"), "4") + e.set(qn("w:color"), color) + borders.append(e) + table._tbl.tblPr.append(borders) + + +def grid(table, color="D7DCE5"): + borders = OxmlElement("w:tblBorders") + for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): + e = OxmlElement(f"w:{edge}") + e.set(qn("w:val"), "single") + e.set(qn("w:sz"), "4") + e.set(qn("w:color"), color) + borders.append(e) + table._tbl.tblPr.append(borders) + + +def cell_pad(table, top=60, bottom=60, left=100, right=100): + mar = OxmlElement("w:tblCellMar") + for side, val in (("top", top), ("bottom", bottom), ("left", left), ("right", right)): + e = OxmlElement(f"w:{side}") + e.set(qn("w:w"), str(val)) + e.set(qn("w:type"), "dxa") + mar.append(e) + table._tbl.tblPr.append(mar) + + +def widths(table, ws): + for row in table.rows: + for i, w in enumerate(ws): + if i < len(row.cells): + row.cells[i].width = Inches(w) + + +def run(p, text, size=10, bold=False, color=DARK, italic=False, caps=False): + r = p.add_run(text) + r.bold = bold + r.italic = italic + r.font.size = Pt(size) + r.font.color.rgb = color + if caps: + rPr = r._element.get_or_add_rPr() + c = OxmlElement("w:caps") + c.set(qn("w:val"), "true") + rPr.append(c) + return r + + +def spacer(doc, pt=6): + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(0) + p.paragraph_format.space_before = Pt(0) + p.add_run("").font.size = Pt(pt) + + +# โ”€โ”€ building blocks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +def section(doc, text): + spacer(doc, 4) + t = doc.add_table(rows=1, cols=1) + no_borders(t) + cell_pad(t, top=50, bottom=50, left=120, right=120) + c = t.cell(0, 0) + shade(c, "0B3D91") + p = c.paragraphs[0] + run(p, text, size=9.5, bold=True, color=WHITE, caps=True) + spacer(doc, 2) + + +def info_grid(doc, pairs, cols=2): + rows = (len(pairs) + cols - 1) // cols + t = doc.add_table(rows=rows, cols=cols) + hairlines(t) + cell_pad(t, top=70, bottom=70, left=40, right=160) + for i, (label, value) in enumerate(pairs): + cell = t.cell(i // cols, i % cols) + cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER + p = cell.paragraphs[0] + p.paragraph_format.space_after = Pt(1) + run(p, label, size=7.5, bold=True, color=LIGHT, caps=True) + p2 = cell.add_paragraph() + p2.paragraph_format.space_before = Pt(0) + run(p2, "โ€”" if value in (None, "") else str(value), size=10.5, color=DARK) + widths(t, [3.45] * cols) + return t + + +def checklist(doc, title, items, per_row=3): + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(3) + run(p, title, size=8.5, bold=True, color=GREY, caps=True) + rows = (len(items) + per_row - 1) // per_row + t = doc.add_table(rows=rows, cols=per_row) + no_borders(t) + cell_pad(t, top=30, bottom=30, left=20, right=40) + for i, item in enumerate(items): + run(t.cell(i // per_row, i % per_row).paragraphs[0], "โ˜ " + item, size=9.5, color=DARK) + + +def sign_block(doc, roles): + t = doc.add_table(rows=len(roles), cols=3) + no_borders(t) + cell_pad(t, top=130, bottom=40, left=0, right=80) + for i, role in enumerate(roles): + run(t.cell(i, 0).paragraphs[0], role, size=9, bold=True, color=GREY, caps=True) + run(t.cell(i, 1).paragraphs[0], "โ€”" * 26, size=10, color=RGBColor(0xC2, 0xC8, 0xD2)) + run(t.cell(i, 2).paragraphs[0], "Date: " + "โ€”" * 10, size=9, color=GREY) + widths(t, [1.7, 3.2, 1.4]) + + +# โ”€โ”€ main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +def main(): + if len(sys.argv) < 3: + print(json.dumps({"error": "usage: gen_workorder.py "})) + return + with open(sys.argv[1], "r", encoding="utf-8") as f: + s = json.load(f) + out_path = sys.argv[2] + g = lambda k, d="": s.get(k) if s.get(k) not in (None, "") else d # noqa: E731 + + doc = Document() + normal = doc.styles["Normal"] + normal.font.name = "Calibri" + normal.font.size = Pt(10) + normal.font.color.rgb = DARK + for sec in doc.sections: + sec.left_margin = sec.right_margin = Inches(0.75) + sec.top_margin = sec.bottom_margin = Inches(0.6) + + # โ”€โ”€ Header band โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + head = doc.add_table(rows=1, cols=2) + no_borders(head) + cell_pad(head, top=0, bottom=0, left=0, right=0) + L = head.cell(0, 0).paragraphs[0] + run(L, g("city", "City of Philadelphia"), size=9, bold=True, color=NAVY) + L.add_run("\n") + run(L, g("department", "Department of Streets ยท Road Maintenance Division"), size=8.5, color=GREY) + L.add_run("\n\n") + run(L, "Pothole Repair Work Order", size=19, bold=True, color=DARK) + + R = head.cell(0, 1) + R.vertical_alignment = WD_ALIGN_VERTICAL.TOP + rp = R.paragraphs[0] + rp.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run(rp, "WORK ORDER NO.", size=7.5, bold=True, color=LIGHT, caps=True) + rp2 = R.add_paragraph() + rp2.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run(rp2, g("workOrderId", "WO-________"), size=15, bold=True, color=NAVY) + rp3 = R.add_paragraph() + rp3.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run(rp3, "Issued ", size=9, color=GREY) + run(rp3, g("issuedDate", "____________"), size=9, bold=True, color=DARK) + rp4 = R.add_paragraph() + rp4.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run(rp4, "Priority ", size=9, color=GREY) + run(rp4, g("priority", "________"), size=9, bold=True, color=RGBColor(0xC2, 0x41, 0x0C)) + run(rp4, " Status ", size=9, color=GREY) + run(rp4, g("status", "OPEN"), size=9, bold=True, color=DARK) + widths(head, [4.0, 3.0]) + + # navy rule + rule = doc.add_paragraph() + rule.paragraph_format.space_before = Pt(4) + rule.paragraph_format.space_after = Pt(2) + pbdr = OxmlElement("w:pBdr") + b = OxmlElement("w:bottom") + b.set(qn("w:val"), "single") + b.set(qn("w:sz"), "18") + b.set(qn("w:color"), "0B3D91") + pbdr.append(b) + rule._p.get_or_add_pPr().append(pbdr) + + # โ”€โ”€ Sections โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section(doc, "Request Information") + info_grid(doc, [ + ("Case / Request ID", g("caseId")), + ("Date Reported", g("reportedDate")), + ("Reported By", g("reportedBy", "Citizen")), + ("Source", g("source", "Citizen Portal (PotholeIQ)")), + ("AI Review Status", g("aiReviewStatus")), + ("Duplicate Status", g("duplicateStatus")), + ]) + + section(doc, "Location") + info_grid(doc, [ + ("Address", g("address")), + ("GPS Coordinates", g("coords")), + ("Ward", g("ward")), + ("District", g("district")), + ("Maintenance Zone", g("zone")), + ("Cross Streets", g("crossStreets", "โ€”")), + ]) + + section(doc, "Defect Description") + info_grid(doc, [ + ("Defect Type", g("defectType", "Pothole")), + ("Pothole Size (AI)", g("size")), + ("Severity Score", g("severityScore")), + ("Severity Level", g("severityLevel")), + ("Road Surface", g("roadSurface", "Asphalt")), + ("Weather Risk", g("weatherRisk")), + ]) + + if s.get("summary"): + section(doc, "AI Case Summary") + run(doc.add_paragraph(), s.get("summary"), size=10, color=DARK) + + img = s.get("imagePath") + if img and os.path.exists(img): + section(doc, "Site Photo (Pre-Repair)") + try: + doc.add_picture(img, width=Inches(4.4)) + run(doc.add_paragraph(), "Citizen-submitted, AI-classified photo. Verify extent on arrival.", size=8.5, italic=True, color=GREY) + except Exception as e: # noqa: BLE001 + run(doc.add_paragraph(), f"[photo not embedded: {e}]", size=9, color=GREY) + elif s.get("photoUrl"): + section(doc, "Site Photo (Pre-Repair)") + p = doc.add_paragraph() + run(p, "View photo: ", size=9.5, bold=True, color=GREY) + run(p, s.get("photoUrl"), size=9.5, color=NAVY) + + section(doc, "Assignment & Scheduling") + info_grid(doc, [ + ("Assigned Crew / Foreman", g("assignedCrew", "____________________")), + ("Crew ID", g("crewId", "____________")), + ("Scheduled Date", g("scheduledDate", "____________")), + ("Target Completion (SLA)", g("targetDate", "____________")), + ("Estimated Labor Hours", g("estHours", "________")), + ("Authorized By", g("authorizedBy", "____________________")), + ]) + + section(doc, "Materials & Equipment") + checklist(doc, "Materials (check + enter quantity)", [ + "Cold Mix Asphalt ____ t", "Hot Mix Asphalt ____ t", "Tack Coat", + "Crack Sealant", "Aggregate / Gravel ____", "Other: __________", + ]) + spacer(doc, 3) + checklist(doc, "Equipment", [ + "Asphalt Roller", "Plate Compactor", "Dump Truck", + "Air Compressor", "Saw Cutter", "Hand Tools", + ]) + + section(doc, "Labor & Cost") + cost = doc.add_table(rows=5, cols=5) + grid(cost) + cell_pad(cost, top=50, bottom=50, left=90, right=90) + for j, h in enumerate(["Crew Member", "Role", "Hours", "Rate ($)", "Cost ($)"]): + c = cost.cell(0, j) + shade(c, "EEF2F9") + run(c.paragraphs[0], h, size=8.5, bold=True, color=NAVY) + widths(cost, [2.4, 1.4, 0.9, 1.0, 1.2]) + tot = doc.add_paragraph() + tot.alignment = WD_ALIGN_PARAGRAPH.RIGHT + tot.paragraph_format.space_before = Pt(4) + run(tot, "Labor $ ______ Materials $ ______ Equipment $ ______ ", size=9.5, color=GREY) + run(tot, "TOTAL $ __________", size=10, bold=True, color=DARK) + + section(doc, "Completion & Sign-Off") + run(doc.add_paragraph(), "Work Performed", size=8.5, bold=True, color=GREY, caps=True) + for _ in range(2): + p = doc.add_paragraph() + run(p, "โ€”" * 78, size=10, color=RGBColor(0xD7, 0xDC, 0xE5)) + spacer(doc, 2) + info_grid(doc, [ + ("Date Started", "____________"), + ("Date Completed", "____________"), + ("Actual Labor Hours", "________"), + ("After Photo Attached", "โ˜ Yes โ˜ No"), + ]) + spacer(doc, 4) + sign_block(doc, ["Crew Foreman", "Supervisor Approval", "Inspector Sign-Off"]) + + section(doc, "Safety / Traffic Control") + checklist(doc, "Measures used", [ + "Cones / Barricades", "Flagger", "Lane Closure", + "Advance Warning Signage", "Arrow Board", "None Required", + ]) + + foot = doc.add_paragraph() + foot.paragraph_format.space_before = Pt(10) + run(foot, "Generated by PotholeIQ" + (" ยท " + g("issuedDate") if g("issuedDate") else ""), size=8, italic=True, color=LIGHT) + + doc.save(out_path) + print(json.dumps({"ok": True, "output": out_path})) + + +if __name__ == "__main__": + main() diff --git a/ai/pothole-yolov8.pt b/ai/pothole-yolov8.pt new file mode 100644 index 0000000000000000000000000000000000000000..1ab77bc055211ace318c8c229e9ac728b2a88cd6 --- /dev/null +++ b/ai/pothole-yolov8.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af2ac6ce7bfec72e71643659ac946caf80ced84869e526a60135c457abfbb200 +size 22524266 diff --git a/ai/pothole_server.py b/ai/pothole_server.py new file mode 100644 index 0000000000000000000000000000000000000000..70654821a92a94651e53ae0834233c131b26cbad --- /dev/null +++ b/ai/pothole_server.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +"""Persistent YOLOv8 pothole classifier worker (real-time). + +Loads the model ONCE at startup, then serves one request per line from stdin +and writes one JSON result per line to stdout. This avoids the multi-second +cold start of loading torch + the model on every request. + +Protocol: + startup -> emits {"ready": true} (or {"error": "..."} and exits) + stdin -> one image file path per line ("__quit__" to stop) + stdout -> one JSON result per line: + {"isPothole":bool,"confidence":float,"size":"Small|Medium|Large"|null, + "count":int,"largestAreaFraction":float,"boxes":[...],"model":"..."} + or {"error":"..."} for that request. +""" +import sys +import os +import json + +# Size = area (as a fraction of the whole frame) of the largest *confident* pothole +# detection. A single 2D photo has no true scale, so box area is a heuristic proxy. +# >= SIZE_LARGE -> Large +# >= SIZE_MEDIUM -> Medium +# else -> Small +# Thresholds are calibrated to the ground-truth pothole box-area distribution +# (3,625 annotated boxes): SIZE_MEDIUM ~= 40th percentile, SIZE_LARGE ~= 82nd +# percentile. That yields a sensible operational split of roughly 40% Small / +# 42% Medium / 18% Large instead of over-calling everything "Small". +SIZE_LARGE = 0.09 +SIZE_MEDIUM = 0.013 +# CONF_THRESHOLD: minimum confidence for a box to count as a detection at all. +CONF_THRESHOLD = 0.25 +# SIZE_CONF_FLOOR: a box must clear this higher bar before its area is allowed to +# drive the size label. This stops low-confidence, sprawling false boxes (e.g. a +# shadow spanning the frame) from inflating a real, smaller pothole to "Large". +SIZE_CONF_FLOOR = 0.45 +MODEL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pothole-yolov8.pt") + + +def emit(obj): + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + + +def classify(model, image_path): + if not os.path.exists(image_path): + return {"error": "image not found: " + image_path} + try: + result = model.predict(image_path, conf=CONF_THRESHOLD, verbose=False)[0] + h, w = result.orig_shape + frame_area = float(h * w) if h and w else 1.0 + + boxes = [] + max_frac = 0.0 # largest box area (any confidence) โ€” reported for transparency + max_conf = 0.0 # best detection confidence + size_frac = 0.0 # area that actually drives the size label (confident boxes only) + best_conf_frac = 0.0 # area of the single highest-confidence box (fallback) + best_conf = -1.0 + for b in result.boxes: + x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist()) + conf = float(b.conf[0]) + frac = ((x2 - x1) * (y2 - y1)) / frame_area + boxes.append({ + "x1": round(x1), "y1": round(y1), "x2": round(x2), "y2": round(y2), + "conf": round(conf, 3), "areaFraction": round(frac, 4), + }) + max_frac = max(max_frac, frac) + max_conf = max(max_conf, conf) + if conf >= SIZE_CONF_FLOOR: + size_frac = max(size_frac, frac) + if conf > best_conf: + best_conf = conf + best_conf_frac = frac + + is_pothole = len(boxes) > 0 + # Prefer the largest confident pothole; if none clear the floor, fall back to + # the area of the single most-confident detection (never a noisy wide box). + sizing_frac = size_frac if size_frac > 0 else best_conf_frac + if not is_pothole: + size = None + elif sizing_frac >= SIZE_LARGE: + size = "Large" + elif sizing_frac >= SIZE_MEDIUM: + size = "Medium" + else: + size = "Small" + + return { + "isPothole": is_pothole, + "confidence": round(max_conf, 3), + "size": size, + "count": len(boxes), + "largestAreaFraction": round(max_frac, 4), + "sizeAreaFraction": round(sizing_frac, 4), + "boxes": boxes, + "model": "peterhdd/pothole-detection-yolov8", + } + except Exception as e: # noqa: BLE001 + return {"error": str(e)} + + +def main(): + if not os.path.exists(MODEL_FILE): + emit({"error": "model file missing: " + MODEL_FILE}) + return + try: + from ultralytics import YOLO + except Exception as e: # noqa: BLE001 + emit({"error": "ultralytics not installed: " + str(e)}) + return + try: + model = YOLO(MODEL_FILE) + except Exception as e: # noqa: BLE001 + emit({"error": "model load failed: " + str(e)}) + return + + emit({"ready": True}) + + for line in sys.stdin: + path = line.strip() + if not path: + continue + if path == "__quit__": + break + emit(classify(model, path)) + + +if __name__ == "__main__": + main() diff --git a/authority-routing.json b/authority-routing.json new file mode 100644 index 0000000000000000000000000000000000000000..f011c1f1085be59a8668571f15527bb679f2ce77 --- /dev/null +++ b/authority-routing.json @@ -0,0 +1,13 @@ +{ + "default": { + "authorityName": "Department of Streets โ€” Road Maintenance Division", + "authorityEmails": [ + "road.maintenance@city.example.gov" + ], + "dispatchEmails": [ + "field.dispatch@city.example.gov" + ] + }, + "districts": {}, + "zones": {} +} diff --git a/backend-api.js b/backend-api.js new file mode 100644 index 0000000000000000000000000000000000000000..26f691edd0aeb89bbd9b717f8dbec204669fb9fa --- /dev/null +++ b/backend-api.js @@ -0,0 +1,293 @@ +'use strict'; + +(function initPotholeBackend(globalScope) { + const API_PREFIX = '/api'; + + function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); + } + + function deepMerge(baseValue, incomingValue) { + if (!isPlainObject(baseValue) || !isPlainObject(incomingValue)) { + return incomingValue === undefined ? baseValue : incomingValue; + } + + const result = { ...baseValue }; + for (const [key, incoming] of Object.entries(incomingValue)) { + const existing = result[key]; + if (Array.isArray(incoming)) { + result[key] = incoming.slice(); + continue; + } + if (isPlainObject(existing) && isPlainObject(incoming)) { + result[key] = deepMerge(existing, incoming); + continue; + } + if (incoming !== undefined) { + result[key] = incoming; + } + } + return result; + } + + function stripPhotoDataUrl(value) { + if (Array.isArray(value)) { + return value.map((item) => stripPhotoDataUrl(item)); + } + + if (!isPlainObject(value)) { + return value; + } + + const clone = {}; + for (const [key, child] of Object.entries(value)) { + if (key === 'photoDataUrl') continue; + clone[key] = stripPhotoDataUrl(child); + } + return clone; + } + + function toTimestamp(value) { + const parsed = Date.parse(String(value || '')); + return Number.isFinite(parsed) ? parsed : 0; + } + + function caseFreshness(record) { + if (!record || typeof record !== 'object') return 0; + const report = record.report || {}; + return Math.max( + toTimestamp(record.updatedAt), + toTimestamp(record.statusUpdatedAt), + toTimestamp(record.ts), + toTimestamp(record.timestamp), + toTimestamp(report.workflowLastUpdatedAt), + toTimestamp(report.resolvedAt), + toTimestamp(report.supervisor?.at), + toTimestamp(report.enrichment?.enrichedAt), + toTimestamp(report.submittedAt) + ); + } + + function mergeCaseLists(localCases, remoteCases) { + const merged = new Map(); + const allCases = [...(Array.isArray(localCases) ? localCases : []), ...(Array.isArray(remoteCases) ? remoteCases : [])]; + + allCases.forEach((record) => { + if (!record || !record.caseId) return; + const existing = merged.get(record.caseId); + if (!existing) { + merged.set(record.caseId, record); + return; + } + + const existingFreshness = caseFreshness(existing); + const nextFreshness = caseFreshness(record); + const nextPreferred = nextFreshness >= existingFreshness; + merged.set( + record.caseId, + nextPreferred ? deepMerge(existing, record) : deepMerge(record, existing) + ); + }); + + return Array.from(merged.values()).sort((a, b) => { + const aTs = caseFreshness(a); + const bTs = caseFreshness(b); + if (aTs !== bTs) return aTs - bTs; + return String(a.caseId || '').localeCompare(String(b.caseId || '')); + }); + } + + async function request(path, options = {}) { + const response = await fetch(`${API_PREFIX}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + ...options, + }); + + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + const errorMessage = payload?.error || `Request failed with HTTP ${response.status}`; + throw new Error(errorMessage); + } + + return payload; + } + + async function health() { + const payload = await request('/health', { method: 'GET' }); + return payload?.ok === true; + } + + async function getCases() { + const payload = await request('/cases', { method: 'GET' }); + return Array.isArray(payload?.cases) ? payload.cases : []; + } + + async function getCase(caseId) { + if (!caseId) return null; + const payload = await request(`/cases/${encodeURIComponent(caseId)}`, { method: 'GET' }); + return payload?.case || null; + } + + async function upsertCase(record) { + const payload = await request('/cases/upsert', { + method: 'POST', + body: JSON.stringify(stripPhotoDataUrl(record || {})), + }); + return payload?.case || null; + } + + async function bulkUpsertCases(cases) { + const payload = await request('/cases/bulk-upsert', { + method: 'POST', + body: JSON.stringify({ + cases: Array.isArray(cases) ? cases.map((record) => stripPhotoDataUrl(record || {})) : [], + }), + }); + return Array.isArray(payload?.cases) ? payload.cases : []; + } + + async function patchCase(caseId, patch) { + if (!caseId) throw new Error('caseId is required.'); + const payload = await request(`/cases/${encodeURIComponent(caseId)}/patch`, { + method: 'POST', + body: JSON.stringify(stripPhotoDataUrl(patch || {})), + }); + return payload?.case || null; + } + + async function submitSupervisorDecision(caseId, decisionPayload = {}) { + if (!caseId) throw new Error('caseId is required.'); + const payload = await request('/workflow/supervisor-decision', { + method: 'POST', + body: JSON.stringify(stripPhotoDataUrl({ + caseId, + ...decisionPayload, + })), + }); + return payload || null; + } + + async function submitCrewCloseout(caseId, closeoutPayload = {}) { + if (!caseId) throw new Error('caseId is required.'); + const payload = await request('/workflow/crew-closeout', { + method: 'POST', + body: JSON.stringify(stripPhotoDataUrl({ + caseId, + ...closeoutPayload, + })), + }); + return payload || null; + } + + async function submitDispatchPlan(dispatchPayload = {}) { + const payload = await request('/workflow/dispatch-plan', { + method: 'POST', + body: JSON.stringify(stripPhotoDataUrl(dispatchPayload || {})), + }); + return payload || null; + } + + async function resolveCase(caseId, resolvePayload = {}) { + if (!caseId) throw new Error('caseId is required.'); + const payload = await request('/workflow/resolve-case', { + method: 'POST', + body: JSON.stringify(stripPhotoDataUrl({ + caseId, + ...resolvePayload, + })), + }); + return payload || null; + } + + async function uploadCaseFile(caseId, fileName, fileBlob, contentType = '') { + if (!caseId) throw new Error('caseId is required.'); + if (!fileName) throw new Error('fileName is required.'); + if (!(fileBlob instanceof Blob)) { + throw new Error('fileBlob must be a Blob or File.'); + } + + const response = await fetch( + `${API_PREFIX}/box/upload-case-file?caseId=${encodeURIComponent(caseId)}&fileName=${encodeURIComponent(fileName)}`, + { + method: 'POST', + headers: contentType ? { 'Content-Type': contentType } : {}, + body: fileBlob, + } + ); + + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + throw new Error(payload?.error || `Request failed with HTTP ${response.status}`); + } + + return payload?.upload || null; + } + + async function hydrateCasesIntoLocalStorage(storageKey = 'submitted_cases') { + if (typeof localStorage === 'undefined') return null; + + let localCases = []; + try { + localCases = JSON.parse(localStorage.getItem(storageKey) || '[]'); + } catch { + localCases = []; + } + + const remoteCases = await getCases(); + const mergedCases = mergeCaseLists(localCases, remoteCases); + localStorage.setItem(storageKey, JSON.stringify(mergedCases)); + + const remoteIds = new Set(remoteCases.map((item) => item?.caseId).filter(Boolean)); + const unsyncedLocal = localCases.filter((item) => item?.caseId && !remoteIds.has(item.caseId)); + if (unsyncedLocal.length) { + bulkUpsertCases(unsyncedLocal).catch((err) => { + console.warn('[PotholeBackend] could not sync local-only cases:', err); + }); + } + + return mergedCases; + } + + async function syncLocalStorageCases(storageKey = 'submitted_cases') { + if (typeof localStorage === 'undefined') return []; + try { + const cases = JSON.parse(localStorage.getItem(storageKey) || '[]'); + return bulkUpsertCases(cases); + } catch (err) { + console.warn('[PotholeBackend] could not read local cases for sync:', err); + return []; + } + } + + globalScope.PotholeBackend = { + health, + getCases, + getCase, + upsertCase, + bulkUpsertCases, + patchCase, + submitSupervisorDecision, + submitDispatchPlan, + submitCrewCloseout, + resolveCase, + uploadCaseFile, + hydrateCasesIntoLocalStorage, + syncLocalStorageCases, + mergeCaseLists, + }; +})(window); diff --git a/box-build/BOX_APP_CONSOLE.md b/box-build/BOX_APP_CONSOLE.md new file mode 100644 index 0000000000000000000000000000000000000000..2d285b5913a0b3870f79702d3166438994384ca0 --- /dev/null +++ b/box-build/BOX_APP_CONSOLE.md @@ -0,0 +1,123 @@ +# Box App โ€” Pothole Operations Console Build Guide + +This builds the operational dashboard + review console as a **Box App** (Box's no-code app +builder over your content + metadata), replacing the custom [ops-review.html](../ops-review.html). + +> **Accuracy note:** the Box Apps no-code builder's exact control names evolve. This guide is +> correct at the structure/concept level (data source โ†’ views โ†’ fields โ†’ actions โ†’ roles). +> Confirm the equivalent control as you build, and tell me what you see if a step doesn't match โ€” +> I'll adjust. + +> **Hard dependency:** the app reads/writes the `potholeCase` metadata template +> ([metadata-template.json](metadata-template.json)). Build that template first, and have at +> least a few enriched cases in `Pothole Operations/Cases/` so the views show real data. + +--- + +## App: "Pothole Operations Console" + +**Data source:** the `potholeCase` metadata template, scoped to the +`Pothole Operations/Cases/` folder tree. Each case folder = one record; the case photo + +generated PDFs are the folder's files. + +--- + +### View 1 โ€” Operations Queue (default landing) + +A filterable table of all cases. + +- **Columns:** `caseId`, `status`, `severityLevel`, `ward`, `address`, `duplicateStatus`, + `submittedAt`, `assignedCrew`. +- **Default sort:** `severityScore` descending (Critical at top). +- **Saved filters / tabs:** + - *Needs Review* โ†’ `status = Under Review` + - *Critical* โ†’ `severityLevel = Critical` + - *Assigned* โ†’ `status = Assigned` + - *Resolved* โ†’ `status = Resolved` +- **Color rules:** Critical = red, High = orange, Medium = amber, Low = grey + (match the colors already in your data's `enrichment.severityColor`). + +### View 2 โ€” Case Detail + +Opens when a row is clicked. + +- **Header:** `caseId` + `status` badge. +- **Photo:** preview the case photo from the folder. +- **AI summary panel:** the Box AI-generated case summary (set up in AI Studio) + + `aiReviewStatus`, `potholeSize`. +- **Severity breakdown:** `severityScore`, `severityLevel`, `weatherRisk` + (the prior/traffic/damage/weather detail lives in the backend `enrichment.severityBreakdown` + โ€” surface it via the AI summary or an embedded note). +- **Location:** `address`, `ward`, `district`, `maintenanceZone`, `latitude`/`longitude` + (link out to a map). +- **Duplicate panel:** `duplicateStatus`, `duplicateCount`, `linkedCaseIds`. +- **Generated docs:** list the work order PDF + audit report from the folder. + +### View 3 โ€” Dashboard (management) + +Charts over the same data source: + +- Cases by `status` (funnel: Submitted โ†’ Under Review โ†’ Assigned โ†’ In Progress โ†’ Resolved). +- Cases by `ward` (bar). +- `severityLevel` distribution (donut). +- Open cases older than N days (SLA watch). + +### View 4 (optional) โ€” Citizen Status (read-only, externally shareable) + +- Single-record lookup by `caseId`. +- Show **only**: `status`, `submittedAt`, `ward`, `resolvedAt`. Hide reporter contact, + internal notes, severity internals. + +--- + +## Actions (buttons that drive the workflow) + +Wire these on the Case Detail view. Each either writes metadata (which a Relay flow reacts to) +or calls your backend: + +| Button | What it does | +|-------------------|------------------------------------------------------------------------------| +| **Approve** | Set `status = Assigned`, `supervisor = {current user}` โ†’ Flow 2/3 fire. | +| **Reject** | Set `status = Rejected` (prompt for reason). | +| **Adjust severity** | Edit `severityLevel` before approving. | +| **Generate work order** | Trigger Box Doc Gen (or it auto-fires from the status change in Flow 3).| +| **Open in city system** | (Optional) HTTPS call to the external work-order system. | + +> If the Box App builder in your tenant can't call outgoing webhooks directly, do it the +> metadata-driven way: the button just sets a metadata field, and **Box Automate (Relay)** +> watches that field and performs the action. This is the more robust pattern anyway. + +--- + +## Roles & permissions + +| Role | Sees / can do | +|------------------|----------------------------------------------------------------------| +| Ops reviewer | All cases; Approve/Reject/Adjust. | +| Ward engineer | Cases for their `ward`; Approve/Reject (Relay assigns the approval). | +| Crew | Assigned cases only; closeout via the **Box Form**, not this app. | +| Management | Dashboard view; read-only. | +| Citizen (public) | View 4 only, via a shared link. | + +--- + +## Build order + +1. Confirm `potholeCase` template exists and a few enriched cases are present. +2. Create the app โ†’ point its data source at `Cases/` + the template. +3. Build View 1 (Queue) โ†’ verify real cases appear. +4. Add View 2 (Detail) + the Approve/Reject buttons โ†’ test one approval end-to-end with + [BOX_AUTOMATE_WORKFLOW.md](BOX_AUTOMATE_WORKFLOW.md) Flow 2 watching the field. +5. Add View 3 (Dashboard) and View 4 (Citizen) last. + +--- + +## How this maps to the old custom app + +| Old custom page | New Box-native home | +|-------------------------------------|----------------------------------| +| [ops-review.html](../ops-review.html) | Box App Views 1โ€“3 + actions | +| [deploy-crew.html](../deploy-crew.html) | Box App action + Relay Flow 3 | +| [crew-closeout.html](../crew-closeout.html) | Box Form + Box Sign (Flow 4) | +| [track.html](../track.html) | Box App View 4 (citizen) | +| [index.html](../index.html) intake | Box Form intake (AI dashcam stays as optional smart client) | diff --git a/box-build/BOX_APP_DASHBOARD.md b/box-build/BOX_APP_DASHBOARD.md new file mode 100644 index 0000000000000000000000000000000000000000..64d4cf186eb2bf01c872e11147ad7a4b7dd37c37 --- /dev/null +++ b/box-build/BOX_APP_DASHBOARD.md @@ -0,0 +1,69 @@ +# PotholeIQ โ€” Box App Dashboard Build (complete) + +Mirrors the ops-review console: hero metrics โ†’ overview charts โ†’ case lists โ†’ case +detail with severity breakdown + photo. All blocks read the `Pothole Case` +(`enterprise/potholeCase`) metadata template. + +**Block types available:** View (filtered metadata list), Chart, Item List, File or Folder, Form, App, AutoFiler, Shortcut. +**A "View" block = one saved Box metadata view** (its own columns/filter/sort, edited in the Box metadata view editor). + +> โš ๏ธ Filters must use EXACT enum values: +> `status`: submitted ยท under review ยท assigned ยท `In progress` ยท resolved ยท rejected +> `severityLevel`: low ยท medium ยท high ยท critical +> `duplicateStatus`: new ยท `duplicate ` *(trailing space)* ยท pending + +--- + +## PAGE 1 โ€” "Operations Overview" (the landing page) + +### Section A โ€” KPI row (Section type: **Grid**) +Four **Chart** blocks. Use chart type **Number/Metric** if available; else a small Donut. +| Block | Source | Measure | Filter | +|---|---|---|---| +| Cases In Queue | Pothole Case | Count | `status` = under review | +| Needs Attention | Pothole Case | Count | `status` โ‰  resolved AND โ‰  rejected | +| Priority Queue | Pothole Case | Count | `severityLevel` = critical OR high | +| Resolved | Pothole Case | Count | `status` = resolved | + +### Section B โ€” distribution (Grid, 2 columns) +| Block | Type | Dimension (group by) | Measure | +|---|---|---|---| +| Cases by Severity | Donut | `severityLevel` | Count | +| Cases by Ward | Column | `ward` | Count | + +### Section C โ€” the breakdown (Grid, 2 columns) +| Block | Type | Detail | +|---|---|---| +| **What Drives Severity** | Bar (grouped) | Measures = Average of `priorNoticeScore`, `trafficScore`, `damageScore`, `weatherScore`. *(If only one measure is allowed, make 4 small bar charts, one per factor avg.)* | +| Cases by Status | Column | group by `status`, Count (the pipeline) | + +--- + +## PAGE 2 โ€” "Case Queue" (the working lists) +One **View** block per saved view (create each, name it, pick Pothole Case, then set columns/filter/sort in the metadata view editor): + +| View block | Filter | Sort | Columns | +|---|---|---|---| +| **All Cases** | none | submittedAt โ†“ | caseId ยท status ยท severityLevel ยท severityScore0100 ยท ward ยท address ยท submittedAt | +| **Triage Queue** | status = under review | severityScore0100 โ†“ | caseId ยท severityScore0100 ยท severityLevel ยท ward ยท address ยท priorNoticeScore ยท trafficScore ยท damageScore ยท weatherScore | +| **Duplicates** | duplicateStatus = `duplicate ` | โ€” | caseId ยท linkedCaseIds ยท duplicateCount ยท severityScore0100 ยท ward | +| **In Progress** | status = assigned / In progress | โ€” | caseId ยท assignedCrew ยท workOrderId ยท severityLevel ยท ward | +| **Resolved** | status = resolved | resolvedAt โ†“ | caseId ยท resolvedAt ยท assignedCrew ยท severityLevel ยท ward | + +--- + +## PAGE 3 โ€” "Case Detail & Evidence" +- **File or Folder block** โ†’ point at the `Potholes/` tree. Opening a case folder previews the + **initial pothole photo** + work order / closeout / audit docs inline. +- **The severity breakdown panel is native:** open any case folder โ†’ Box's **metadata sidebar** + shows all `Pothole Case` fields, including the 4 breakdown **Scores + Details** and the total. + No extra block needed โ€” that's the per-case breakdown. + +Breakdown reads as: Prior Notice _/30 ยท Traffic _/25 ยท Damage _/25 ยท Weather _/10 โ†’ Total /100 โ†’ Level. + +--- + +## Notes +- **No email from the App** โ€” any dispatch/notify routes through Relay or the backend. +- **Views/charts are empty until cases carry `potholeCase` metadata** โ€” backend seeds that. +- Build order: Page 1 charts โ†’ Page 2 views โ†’ Page 3 evidence. Then Save & Preview. diff --git a/box-build/BOX_AUTOMATE_WORKFLOW.md b/box-build/BOX_AUTOMATE_WORKFLOW.md new file mode 100644 index 0000000000000000000000000000000000000000..100ae5a2fcbd9f1b9e4e91574b215b21c288a4fb --- /dev/null +++ b/box-build/BOX_AUTOMATE_WORKFLOW.md @@ -0,0 +1,145 @@ +# Box Automate (Relay) โ€” Pothole Workflow Build Guide + +This guide builds the end-to-end pothole workflow as **Box Relay** flows (the no-code +workflow tool your setup calls "Box Automate"). It is written click-by-click but exact +button labels vary slightly by Box release โ€” confirm the equivalent control in your tenant. + +> **Division of labor:** Relay flows are built in the Box web console (Relay app). The code +> side (metadata template, enrichment Skill, Doc Gen template, backend callback) is in this +> repo. Each flow below names exactly which code asset it touches. + +--- + +## Prerequisites (do these first, in order) + +1. **CCG connection live.** The service account must be able to read/write the case folders. + (Still pending your Box **Enterprise ID** so we can finish this in `data/config.json`.) +2. **Metadata template created.** Create `potholeCase` from + [metadata-template.json](metadata-template.json) โ€” Admin Console โ†’ **Content** โ†’ + **Metadata** โ†’ New Template (or POST it to `/2.0/metadata_templates/schema`). +3. **Folder structure exists:** + - `Pothole Operations/Incoming Reports/` โ† intake drop folder (your current intake + folder id `386202381022`) + - `Pothole Operations/Cases/` โ† case folders get created here as `Ward-XYZ/Case-/` + - `Pothole Operations/Reports/` โ† Doc Gen output + audit reports +4. **Backend reachable from Box cloud.** Relay's *HTTPS Request* action calls your backend. + Expose it with a tunnel (`ngrok http 3030` or `cloudflared`) and note the public URL, + e.g. `https://abc123.ngrok.io`. Your backend already exposes the callback endpoint + `POST /api/box/automate-update` (see [BACKEND_SETUP.md](../BACKEND_SETUP.md)). + +--- + +## Flow 1 โ€” Intake & Enrichment + +**Goal:** when a new report lands, enrich it (severity, ward, duplicate, weather) and stamp +metadata. The hard math (GIS ward mapping, 50โ€“100 m duplicate radius, deterministic severity, +weather) is NOT something Box AI can do โ€” it runs in your enrichment endpoint, which Relay calls. + +- **Trigger:** *When a file is uploaded* to `Pothole Operations/Incoming Reports` + (fire on the `*.json` sidecar so you enrich once per case, not once per photo). +- **Action 1 โ€” HTTPS Request (enrichment):** + - Method: `POST` + - URL: `https:///api/box/enrich` *(endpoint I will add to the backend)* + - Body (JSON): `{ "caseId": "{file name without extension}", "sidecarFileId": "{file id}" }` + - The endpoint computes severityScore/Level, ward/district/zone, duplicateStatus + + linkedCaseIds, weatherRisk and **writes them straight onto the `potholeCase` metadata**. +- **Action 2 โ€” Apply Metadata** (`potholeCase`): set `status = Under Review` if not already set. +- **Action 3 โ€” Create/Move:** move the photo + sidecar into `Cases/Ward-{ward}/Case-{caseId}/`. + *(Relay folder-from-metadata naming is limited; the enrichment endpoint can create the case + folder via API instead โ€” your [box-folders.js](../box-folders.js) already does this.)* + +> **Alternative (more native):** instead of the Relay HTTPS Request, register the enrichment +> endpoint as a **custom Box Skill** on the Incoming folder. Then enrichment fires +> automatically on upload and writes metadata cards โ€” no Relay step needed for Flow 1. + +--- + +## Flow 2 โ€” Review & Approval Routing + +**Goal:** notify the right ward engineer and capture approve / reject / adjust. + +- **Trigger:** *When metadata field updates* โ€” `potholeCase.status` becomes `Under Review`. +- **Condition (severity routing):** + - If `severityLevel = Critical` โ†’ **immediate** path. + - Else โ†’ standard path (optionally a scheduled digest flow). +- **Action โ€” Assign Approval** to the ward engineer: + - Route by `ward` (use a Relay condition per ward โ†’ assignee email, or a single group). + - Approver opens the case in Box web/mobile, reviews the photo + metadata + AI summary, + and can edit `severityLevel` (that's just editing a metadata field) before deciding. +- **On Approve:** + - Set `status = Assigned`, `supervisor = {approver}`. + - HTTPS Request โ†’ `POST /api/box/automate-update` with + `{ "caseId": "...", "workflowStatus": "Assigned", "workflowStage": "crew_dispatch" }` + so your backend DB stays in sync. +- **On Reject:** set `status = Rejected`; HTTPS Request with `workflowStatus: "Rejected"`. + +--- + +## Flow 3 โ€” Work Order Generation (Box Doc Gen) + +**Goal:** on approval, produce the work order / job sheet PDF into the case folder. + +- **Trigger:** `potholeCase.status` becomes `Assigned`. +- **Action โ€” Generate Document (Box Doc Gen):** + - Template: the Doc Gen template I'll create from [workorder_template.html](../workorder_template.html). + - Data source: the `potholeCase` metadata on the case folder (merge tags map 1:1 to the + field keys โ€” see `04` mapping doc once created). + - Output: `Cases/Ward-{ward}/Case-{caseId}/Work-Order-{workOrderId}.pdf`. +- **Action โ€” Apply Metadata:** write `workOrderId`. +- *(Optional)* HTTPS Request to push the work order into the city system of record. + +--- + +## Flow 4 โ€” Crew Closeout, Sign & Audit + +**Goal:** field crew submits proof, signs, case is archived audit-ready. + +- **Trigger:** a **Box Form** submission (crew closeout โ€” built separately) lands a closeout + record / after-photo in the case folder, OR `status` becomes `In Progress` then closeout. +- **Action โ€” Request Signature (Box Sign):** send the completion certificate for the crew + lead's e-signature (replaces the current typed-name field). +- **On signed:** set `status = Resolved`, `resolvedAt = {now}`; HTTPS Request โ†’ + `/api/box/automate-update` with `workflowStatus: "Resolved"` (your backend then auto-builds + the before/after + audit report โ€” see `generateAndUploadAuditReport` in [server.js](../server.js)). +- **Action โ€” Apply Governance:** apply a **retention policy / legal hold** to the case folder + (Box Governance) so the record is audit-ready with full version history. + +--- + +## Field reference (Relay conditions & HTTPS payloads) + +These are the `potholeCase` keys Relay reads/writes, and the matching `report.*` keys your +backend already uses (so the callback payload to `/api/box/automate-update` lines up): + +| Metadata key (Box) | Backend `report.*` key | Used in flow | +|----------------------|-------------------------------|--------------| +| `status` | `workflowStatus` | all | +| `severityLevel` | `enrichment.severityLevel` | 1, 2 | +| `severityScore` | `enrichment.severityScore` | 1, 2 | +| `ward` / `district` | `ward` / `district` | 1, 2, 3 | +| `duplicateStatus` | `enrichment.duplicate.*` | 1 | +| `weatherRisk` | `enrichment.weather.riskLevel`| 1 | +| `assignedCrew` | `dispatchPlan.crewName` | 3, 4 | +| `workOrderId` | `workOrderId` | 3 | +| `resolvedAt` | `resolvedAt` | 4 | + +**Backend callback contract** (already implemented): `POST /api/box/automate-update` +```json +{ + "caseId": "POT-20260604-Q2H0GR", + "workflowStage": "crew_dispatch", + "workflowStatus": "Assigned", + "assignedTo": "ward-12-crew@city.gov" +} +``` +If you set `BOX_AUTOMATE_SECRET` on the server, add the same value as an +`X-Box-Automate-Secret` header on every Relay HTTPS Request action. + +--- + +## What I still owe you (code side) + +- [ ] `POST /api/box/enrich` endpoint (wraps existing [enrichment.js](../enrichment.js) + + [gis.js](../gis.js)) that writes `potholeCase` metadata directly โ€” for Flow 1. +- [ ] Box Doc Gen template + field-mapping doc โ€” for Flow 3. +- [ ] (Optional) custom Box Skill manifest if you prefer the auto-on-upload enrichment path. diff --git a/box-build/REVISED_WORKFLOW.md b/box-build/REVISED_WORKFLOW.md new file mode 100644 index 0000000000000000000000000000000000000000..0604fcd09bf0889f2bcda27e1677728cd1285346 --- /dev/null +++ b/box-build/REVISED_WORKFLOW.md @@ -0,0 +1,149 @@ +# Pothole Operations โ€” Revised Workflow (Recommended) + +This is the recommended end-to-end design after reviewing the codebase, the Box Relay +build, and the platform constraints. It is optimized to be **robust** (works even if Box AI +is disabled), **demoable now** (no tunnel, no external city system required), and **honest** +(every step does what it claims). + +--- + +## Design decisions (the "why") + +1. **The backend computes enrichment AND writes Box metadata directly at upload.** + Severity, GPSโ†’ward, duplicate, and weather are computed in our service the moment a report + is submitted, written into the JSON, and **also applied as Box metadata via the Box API + during upload.** This is the single most important change. + - โœ… The workflow does **not depend on Box AI / Box Extract being enabled** (which the Relay + screenshots showed as "no longer enabled"). If Extract is on, it's a confirming bonus. + - โœ… **No tunnel required** โ€” the backend *pushes* complete data to Box; Box never calls back + just to enrich. + - โœ… Relay, the Box App, Doc Gen, and Sign all run natively on clean metadata. + +2. **Metadata is the contract.** Box's no-code tools branch and route on **metadata**, never on + raw file contents. The `potholeCase` template ([metadata-template.json](metadata-template.json)) + is the shared schema everything reads. + +3. **Source of truth = backend; Box = content + metadata + workflow + audit.** The backend owns + live state; Box owns the authoritative files, the metadata mirror that drives Relay/App, and + the audit record. Every Box-side action (approval, closeout) calls back to the backend so the + two never drift. + +4. **Work order is generated in Box (Doc Gen), not an external city system.** Removes the only + hard external dependency. "Push to city system via REST" stays as an *optional future* step. + +5. **Citizens track status in the portal, never in Box.** No Box login for the public. + +6. **Six statuses only:** `Submitted` ยท `Under Review` ยท `Assigned` ยท `In Progress` ยท `Resolved` ยท `Rejected`. + +--- + +## End-to-end flow + +``` +CITIZEN PORTAL (web/mobile) + โ”‚ photo(s) + location + reporter info + on-device AI size detection + โ–ผ +BACKEND โ”€โ”€ enrich at submission โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ โ€ข GPS โ†’ ward / district / zone (point-in-polygon GeoJSON) + โ”‚ โ€ข severity score + level (weighted formula) + โ”‚ โ€ข duplicate check 50โ€“100 m (radius across open cases) + โ”‚ โ€ข weather risk (OpenWeather) + โ”‚ โ€ข assemble COMPLETE JSON + โ–ผ +BOX โ”€โ”€ backend uploads photo + complete JSON + applies metadata โ”˜ + โ”‚ Incoming Reports/ (+ potholeCase metadata set on the file) + โ–ผ +BOX RELAY (triggers on upload; runs on metadata) + โ”‚ + โ”œโ”€โ”€ duplicate? โ”€โ”€โ–บ link to existing case ยท boost severity ยท notify owner ยท STOP + โ”‚ + โ””โ”€โ”€ new โ”€โ”€โ–บ create Cases/Ward-{ward}/Case-{caseId}/ ยท status = Under Review + โ”‚ + โ–ผ + REVIEW โ”€โ”€ notify ward engineer (folder link ยท photo ยท AI summary ยท nearby cases) + โ”‚ engineer approves / adjusts severity / rejects (Box web or mobile) + โ”‚ โ†’ callback to backend (keep state in sync) + โ–ผ + APPROVED โ”€โ”€ backend stamps workOrderId ยท BOX DOC GEN โ†’ Work Order PDF + โ”‚ status = Assigned + โ–ผ + FIELD โ”€โ”€ crew gets job sheet + mobile BOX FORM + โ”‚ repair method ยท materials ยท labor hours ยท after-photo(s) + โ”‚ status = In Progress + โ–ผ + SIGN-OFF โ”€โ”€ BOX SIGN (crew lead signature) + โ”‚ status = Resolved ยท notify supervisor + risk mgmt + โ”‚ backend generates before/after + audit report โ†’ case folder + โ–ผ + AUDIT โ”€โ”€ Box Governance (retention / legal hold) + native version history + โ”‚ +CITIZEN PORTAL โ—„โ”€ status synced from backend (track.html) โ€” no Box login +``` + +--- + +## Phase table + +| # | Phase | Owner | Key actions | Output | Status | +|---|-------|-------|-------------|--------|--------| +| 1 | Intake | Portal | Photo, location (EXIF/device/manual), reporter, on-device AI size | Report posted | `Submitted` | +| 2 | Enrich | **Backend** | ward, severity, duplicate, weather โ†’ complete JSON | Complete JSON | `Submitted` | +| 3 | Store | **Backend โ†’ Box** | Upload photo + JSON; **apply `potholeCase` metadata** | Files + metadata in Box | `Submitted` | +| 4 | Route | Box Relay | Conditional Split on `duplicateStatus`; create case folder | Case folder / dup link | `Under Review` | +| 5 | Review | Ward engineer (Box) | Notify + approve/adjust/reject; callback to backend | Decision | `Under Review` โ†’ `Rejected`/approved | +| 6 | Work order | Box Doc Gen | Stamp `workOrderId`; generate Job Sheet PDF | PDF in folder | `Assigned` | +| 7 | Field | Crew (Box Form) | Method, materials, labor, after-photo | Closeout record | `In Progress` | +| 8 | Sign-off | Box Sign | Signature; notify supervisor/risk; audit report | Signed cert + reports | `Resolved` | +| 9 | Transparency | Portal | Citizen looks up case | Status view | (read) | +| 10 | Audit | Box Governance | Retention, version history, access control | Audit-ready folder | (retained) | + +--- + +## What runs where + +**Backend (our service)** โ€” the things Box can't do: +- GPSโ†’ward (geometry), severity (scoring), duplicate (geo-radius), weather (external API) +- Assemble complete JSON, upload to Box, **apply Box metadata** +- Generate work order PDF source + before/after + audit reports (already implemented) +- Hold authoritative live state; expose status to the citizen portal + +**Box (native, no custom code)** โ€” configured in the console: +- Relay: trigger, duplicate split, folder creation, approval routing, notifications +- Doc Gen: Work Order / Job Sheet PDF +- Box Form: crew closeout ยท Box Sign: signature +- Governance + version history: audit +- (Optional) Box App: ops console + dashboards ยท Box AI: image summary if/when enabled + +--- + +## Why this is the advisable version + +- **Resilient to the disabled-AI problem** โ€” the backend writes metadata directly, so Relay + works whether or not Box Extract/Agent is enabled. +- **No tunnel, no external system** โ€” fully demoable as "files + metadata land in Box and Box + runs the workflow." +- **No drift** โ€” single source of truth + callback on every Box action. +- **Honest on the call** โ€” Box genuinely runs content, workflow, Doc Gen, Sign, and audit; our + service genuinely runs the geospatial scoring it feeds Box. + +--- + +## Build checklist + +**Done** +- [x] `potholeCase` metadata schema โ€” [metadata-template.json](metadata-template.json) +- [x] GIS endpoint no longer 500s; serves local ward GeoJSON if present โ€” [server.js](../server.js) +- [x] Credible authority routing defaults โ€” [authority-routing.json](../authority-routing.json) + +**Next (backend โ€” I can build)** +- [ ] Enrich-at-submission: run ward + severity + duplicate + weather before the sidecar uploads +- [ ] Apply `potholeCase` Box metadata during upload (uses existing metadataScope/templateKey config) +- [ ] Work-order/approval endpoint for the Doc Gen + status sync +- [ ] Box Doc Gen template + field map from [workorder_template.html](../workorder_template.html) + +**Next (Box console โ€” your team)** +- [ ] Connect CCG (needs Enterprise ID + share intake folder with service account) +- [ ] Build the Relay flow per [BOX_AUTOMATE_WORKFLOW.md](BOX_AUTOMATE_WORKFLOW.md) (metadata-driven) +- [ ] Doc Gen template, Box Form, Box Sign, Governance policy +- [ ] (Optional) Box App console per [BOX_APP_CONSOLE.md](BOX_APP_CONSOLE.md) +- [ ] (Optional) drop real ward GeoJSON at `data/ward-boundaries.geojson` diff --git a/box-build/WORKFLOW_ONE_BUILD.md b/box-build/WORKFLOW_ONE_BUILD.md new file mode 100644 index 0000000000000000000000000000000000000000..61674bacc74cee255ebb75ee9d775a4fb664a55f --- /dev/null +++ b/box-build/WORKFLOW_ONE_BUILD.md @@ -0,0 +1,86 @@ +# Workflow One โ€” Intake โ†’ Dispatch (Relay build walkthrough) + +Build this in the Box Relay canvas, top to bottom. After each node, click **+** and pick the +palette item named in **bold**. Workflow name: **Workflow One**. + +**Prereqs:** `Pothole Case` metadata template exists ยท folders `Incoming Detections`, +`Cases/`, `Cases/Duplicates/`, `Cases/Rejected/` exist. + +--- + +## TRIGGER โ€” Flow Triggers โ†’ File Uploaded +- **Folder:** `Incoming Detections` +- **Filter (recommended):** file name *contains* `_metadata.json` + โ†’ fires once per case on the JSON sidecar, not the photo. + +## 1. AI Agents โ†’ Extract Agent +- **Input file:** the triggering file. +- **Map to `Pothole Case` metadata:** `Case ID`, `Latitude`, `Longitude`, `Address`, `Ward`, + `District`, `Pothole Size`, `Severity Score`, `Severity Level`, `Duplicate Status`, + `Weather Risk`, `AI Review Status`. + +## 2. AI Agents โ†’ Box Agent +- **Instruction:** "Read the case file and metadata; write a short plain-language case summary + and confirm the severity level." +- **Output to metadata:** `AI Summary`. + +## 3. Branching โ†’ Conditional Split *(name it "Duplicate?")* +- **Condition:** `Pothole Case` โ€บ `Duplicate Status` **is** `duplicate` +- Creates two paths: **Duplicate** (true) and **New** (Else). + +--- + +### โ–ธ DUPLICATE path +**4a. Outcomes โ†’ File Action โ†’ Move** โ†’ `Cases/Duplicates/` +**5a. Outcomes โ†’ Add Metadata** โ†’ `Status = Under Review` +โ†’ *path ends (no new work order; it's linked via `Linked Case IDs`).* + +--- + +### โ–ธ NEW path +**4b. Outcomes โ†’ Folder Action โ†’ Create Folder** +- **Name (use variables):** `Case-{Case ID}` +- **Parent path:** `Cases / Ward-{Ward}` + +**5b. Outcomes โ†’ File Action โ†’ Move** โ†’ into the new `Case-{Case ID}` folder. + +**6. Outcomes โ†’ Add Metadata** โ†’ `Status = Under Review`. + +**7. Outcomes โ†’ Send Notification** +- **To:** ward engineer (route by `Ward`, or a fixed group for the demo). +- **Message:** include `{AI Summary}`, `{Severity Level}`, and the case folder link. + +**8. Outcomes โ†’ Task Action** *(approval)* +- **Title:** `Pothole Repair Work Order โ€“ {Case ID}` +- **Assignee:** ward engineer +- **Instructions:** "Approve, adjust severity, or reject." + +**9. Branching โ†’ Conditional Split** on the task result โ†’ **Approved** / **Rejected** + +#### โ–น APPROVED +**10. Outcomes โ†’ HTTPS Request** +- **Method:** POST +- **URL:** `https:///api/box/workorder` *(endpoint I'll provide)* +- **Body:** `{ "caseId": "{Case ID}" }` +- Backend stamps `Work Order ID` + generates the **Box Doc Gen** Job Sheet PDF into the folder. + +**11. Outcomes โ†’ Add Metadata** โ†’ `Status = Assigned`, `Work Order ID = {returned id}`. +โ†’ **End of Workflow One.** + +#### โ–น REJECTED +**10r. Outcomes โ†’ Add Metadata** โ†’ `Status = Rejected` +**11r. Outcomes โ†’ File Action โ†’ Move** โ†’ `Cases/Rejected/` +**12r. Outcomes โ†’ Send Notification** โ†’ reporter (if contact on file). + +--- + +## Notes +- **Doc Gen is not a Relay action** in your palette โ€” that's why step 10 is an **HTTPS Request** + to the backend (which runs Doc Gen). It needs the backend reachable from Box (a tunnel for the + demo). If the backend isn't up yet, **stop Workflow One at step 11 (`Status = Assigned`)** and + generate the work order later โ€” the flow is still complete and demoable. +- **Guard against loops:** the Add Metadata steps write `Status`; make sure no trigger in this + flow is "metadata updated," or it can re-fire. This flow triggers on *file upload* only, so + you're safe. +- **Crew closeout, Box Sign, resolve** all live in **Workflow Two** (triggered by the Box Form), + not here. diff --git a/box-build/WORKFLOW_TWO_BUILD.md b/box-build/WORKFLOW_TWO_BUILD.md new file mode 100644 index 0000000000000000000000000000000000000000..e068187f8b2c13b14d408429049e2fbd371980e5 --- /dev/null +++ b/box-build/WORKFLOW_TWO_BUILD.md @@ -0,0 +1,65 @@ +# Workflow Two โ€” Crew Reporting & Closeout + +Triggered by the crew **Box Form** submission. Build the Form first (it's the trigger), then the flow. + +**Prereqs:** Box Form "Crew Closeout" built ยท folder `Cases/Closeouts/` (where the form saves) ยท +Box Sign enabled ยท Box Governance retention policy on `Cases/` (set once, separately). + +--- + +## Step A โ€” Box Form: "Crew Closeout" (build this first) + +Fields: +| Field | Type | Notes | +|---|---|---| +| Case ID | Text (required) | crew enters/scans the case it belongs to | +| Repair Method | Dropdown | Cold patch / Hot mix / Full-depth | +| Materials Used | Text | | +| Labor Hours | Number | | +| After Photo | File upload (required) | the "after" proof | +| Crew Name | Text | | +| Notes | Long text | optional | + +- **Save destination:** `Cases/Closeouts/` (a single intake folder is simplest). +- If the Form can **map answers to metadata**, map them to the matching Pothole Case fields. + +--- + +## Step B โ€” Workflow Two (Relay) + +**TRIGGER:** Box Form submitted โ€” "Crew Closeout" +*(If there's no native Form trigger, use **File Uploaded โ†’ `Cases/Closeouts/`** instead.)* + +**1. AI Agents โ†’ Extract Agent** *(only if the Form didn't write metadata directly)* +- Pull form values โ†’ Pothole Case metadata: `repairMethod`, `materialsUsed`, `laborHours`, `crewName`, `afterPhotoFileId`. + +**2. Outcomes โ†’ Add Metadata** +- `Status = In Progress` + the closeout fields above. + +**3. Outcomes โ†’ Request Signature (Box Sign)** +- Sign the **completion certificate / closeout report**. +- โš ๏ธ Box Sign needs an actual file to sign. The nice version is the **Doc Gen closeout report** (backend-generated). Until that's wired, point Sign at the **work order PDF** or a simple completion doc. + +**4. Branch on the signature outcome โ†’ Completed / Declined / Expired / Cancelled** + +### โ–น Completed +- **Add Metadata** โ†’ `Status = Resolved`, `resolvedAt = {now}` +- **HTTPS Request โ†’ backend** โ†’ generate **Closeout Report (Doc Gen)** + audit report into the case folder *(backend step โ€” deferred until backend is up; for now skip and just set Resolved)* +- **Send Notification** โ†’ supervisor + risk management (Box users) +- *(Optional)* **File Action โ†’ Apply Classification** โ†’ "Closed โ€“ Retain" (Box Shield/Governance) + +### โ–น Declined +- **Send Notification** โ†’ re-route / fix and resubmit. + +### โ–น Expired +- **Send Notification** โ†’ remind the signer. + +### โ–น Cancelled +- **Add Metadata** โ†’ hold / note. + +--- + +## Notes +- **Box Governance** (retention / legal hold) is a **policy set once on the `Cases/` folder** โ€” it auto-applies, it's not a Relay node. +- **Citizen** sees `Resolved` via the **portal** (no Box notification to them). +- **Two pieces wait on the backend:** the Doc Gen **closeout report** fill, and (ideally) the document Box Sign signs. Everything else is native Relay and buildable now. diff --git a/box-build/add-breakdown-fields.sh b/box-build/add-breakdown-fields.sh new file mode 100644 index 0000000000000000000000000000000000000000..f70c9a04d18e27d8b5efe8d8081d4dd90f2bbfa3 --- /dev/null +++ b/box-build/add-breakdown-fields.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Adds the 4 severity sub-score floats + 4 detail strings to the potholeCase +# metadata template so the breakdown can be shown in the Box App dashboard. +# Additive only โ€” does not touch existing fields or Relay flows. +# Usage: bash add-breakdown-fields.sh +set -euo pipefail +TOKEN="${1:?Pass a fresh Box dev token as the first argument}" + +curl -s -w "\nHTTP %{http_code}\n" \ + -X PUT "https://api.box.com/2.0/metadata_templates/enterprise/potholeCase/schema" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json-patch+json" \ + -d '[ + {"op":"addField","data":{"type":"float","key":"priorNoticeScore","displayName":"Prior Notice Score (0-30)"}}, + {"op":"addField","data":{"type":"float","key":"trafficScore","displayName":"Traffic Score (0-25)"}}, + {"op":"addField","data":{"type":"float","key":"damageScore","displayName":"Damage Score (0-25)"}}, + {"op":"addField","data":{"type":"float","key":"weatherScore","displayName":"Weather Score (0-10)"}}, + {"op":"addField","data":{"type":"string","key":"priorNoticeDetail","displayName":"Prior Notice Detail"}}, + {"op":"addField","data":{"type":"string","key":"trafficDetail","displayName":"Traffic Detail"}}, + {"op":"addField","data":{"type":"string","key":"damageDetail","displayName":"Damage Detail"}}, + {"op":"addField","data":{"type":"string","key":"weatherDetail","displayName":"Weather Detail"}} +]' diff --git a/box-build/metadata-template.json b/box-build/metadata-template.json new file mode 100644 index 0000000000000000000000000000000000000000..de86af949c042ba94a1eab415c2a831867b7f51c --- /dev/null +++ b/box-build/metadata-template.json @@ -0,0 +1,93 @@ +{ + "_comment": "Box metadata template for a pothole case. This is the single shared schema that Box Automate (Relay) conditions, Box Doc Gen merge fields, the Box App console, and the enrichment Skill all read and write. Create it once via the Box Admin Console (Content > Metadata) or POST this payload to https://api.box.com/2.0/metadata_templates/schema. Field keys match the report.* fields the backend already produces in data/cases.json.", + "scope": "enterprise", + "templateKey": "potholeCase", + "displayName": "Pothole Case", + "hidden": false, + "copyInstanceOnItemCopy": true, + "fields": [ + { "type": "string", "key": "caseId", "displayName": "Case ID" }, + { + "type": "enum", + "key": "status", + "displayName": "Status", + "options": [ + { "key": "Submitted" }, + { "key": "Under Review" }, + { "key": "Assigned" }, + { "key": "In Progress" }, + { "key": "Resolved" }, + { "key": "Rejected" } + ] + }, + { "type": "date", "key": "submittedAt", "displayName": "Submitted At" }, + { "type": "string", "key": "address", "displayName": "Address" }, + { "type": "string", "key": "ward", "displayName": "Ward" }, + { "type": "string", "key": "district", "displayName": "District" }, + { "type": "string", "key": "maintenanceZone", "displayName": "Maintenance Zone" }, + { "type": "float", "key": "latitude", "displayName": "Latitude" }, + { "type": "float", "key": "longitude", "displayName": "Longitude" }, + { "type": "float", "key": "severityScore", "displayName": "Severity Score (0-100)" }, + { + "type": "enum", + "key": "severityLevel", + "displayName": "Severity Level", + "options": [ + { "key": "Low" }, + { "key": "Medium" }, + { "key": "High" }, + { "key": "Critical" } + ] + }, + { + "type": "enum", + "key": "potholeSize", + "displayName": "Pothole Size", + "options": [ + { "key": "Small" }, + { "key": "Medium" }, + { "key": "Large" } + ] + }, + { + "type": "enum", + "key": "duplicateStatus", + "displayName": "Duplicate Status", + "options": [ + { "key": "new" }, + { "key": "duplicate" }, + { "key": "pending" } + ] + }, + { "type": "float", "key": "duplicateCount", "displayName": "Duplicate Count" }, + { "type": "string", "key": "linkedCaseIds", "displayName": "Linked Case IDs" }, + { + "type": "enum", + "key": "weatherRisk", + "displayName": "Weather Risk", + "options": [ + { "key": "low" }, + { "key": "medium" }, + { "key": "high" } + ] + }, + { + "type": "enum", + "key": "aiReviewStatus", + "displayName": "AI Review Status", + "options": [ + { "key": "auto_confirmed" }, + { "key": "manual_review" }, + { "key": "manual_confirmed" }, + { "key": "manual_rejected" } + ] + }, + { "type": "string", "key": "aiSummary", "displayName": "AI Summary" }, + { "type": "string", "key": "assignedCrew", "displayName": "Assigned Crew" }, + { "type": "string", "key": "workOrderId", "displayName": "Work Order ID" }, + { "type": "string", "key": "supervisor", "displayName": "Supervisor / Ward Engineer" }, + { "type": "date", "key": "resolvedAt", "displayName": "Resolved At" }, + { "type": "string", "key": "reporterContact", "displayName": "Reporter Contact" }, + { "type": "string", "key": "boxCaseFolderId", "displayName": "Case Folder ID" } + ] +} diff --git a/box-build/relay-flow-diagram.html b/box-build/relay-flow-diagram.html new file mode 100644 index 0000000000000000000000000000000000000000..9f58fa83bb31fdf152af2a8275d88019be3fa3dd --- /dev/null +++ b/box-build/relay-flow-diagram.html @@ -0,0 +1,277 @@ + + + + + +Pothole Portal โ€” Box Automate (Relay) Flows + + + +
+

Box Automate (Relay) โ€” Pothole Portal

+
Two flows ยท Box Extract + Box Agent enabled ยท backend supplies the GIS & weather data AI can't compute
+ + + +
+ FLOW 1 โ€” Intake โ†’ Dispatch + triggered by file upload +
+ +
+ +
+
โšก
+
File Uploaded
+
TRIGGER ยท Incoming Reports
+
Citizen report lands: photo + _metadata.json.
+
+
+ +
+
โŠž
+
Extract Agent AI
+
BOX EXTRACT ยท file โ†’ metadata
+
Reads the JSON and writes the potholeCase metadata: caseId, location, AI size, road score.
+
+
+ +
+
โœฆ
+
Box Agent โ€” Enrichment & Scoring AI
+
BOX AGENT ยท reasoning
+
Evaluates severity score & level, writes a case summary, and reasons over duplicate likelihood.
+ GPSโ†’ward & weather supplied via HTTPS data lookup (AI can't compute geometry)
+
+
+ +
+
โ—‡
+
Duplicate Check
+
CONDITIONAL SPLIT ยท metadata: duplicateStatus
+
Within 50โ€“100 m of an open case?
+
+ +
+
+
+
+
DUPLICATE
+
โ–ค
+
Link to Parent Case
FILE ACTION + ADD METADATA
+
Move to Duplicates ยท boost severity.
+
END ยท no new work order
+
+
+
NEW REPORT
+
โ–ฃ
+
Create Case Folder
FOLDER ACTION
+
Potholes / Ward-{ward} / Case-{caseId}
+
+
+
+ +
+
M
+
Set Status & Move Into Folder
+
ADD METADATA + FILE ACTION
+
status = Under Review.
+
+ +
โœ‰
+
Notify Ward Engineer
+
SEND NOTIFICATION
+
Folder link ยท photo ยท AI summary ยท severity ยท nearby cases.
+
+ +
โœ“
+
Review & Approval
+
TASK ACTION ยท Approval from authority
+
Approve ยท adjust severity ยท or reject (web / mobile).
+ +
+
+
+
+
REJECTED
+
M
+
Reject & Archive
ADD METADATA + FILE ACTION
+
status = Rejected ยท notify reporter.
+
END
+
+
+
APPROVED
+
โ‡„
+
Generate Work Order
HTTPS โ†’ backend โ†’ Box Doc Gen
+
Stamp workOrderId ยท Job Sheet PDF into folder.
+ No city-system API
+
+
M
+
status = Assigned
ADD METADATA
+
+
+
+
+ +
โ€” job sheet sent ยท crew repairs on site โ€”
+ + +
+ FLOW 2 โ€” Crew Reporting & Closeout + triggered by Box Form submission +
+ +
+ +
+
โ–ฆ
+
Crew Submits Box Form (mobile)
+
TRIGGER ยท Box Form ยท on-site
+
Repair method (cold patch / hot mix / full-depth) ยท materials ยท labor hours ยท after-photo(s) โ†’ case folder.
+
+
+ +
M
+
Record Closeout Data
+
ADD METADATA
+
status = In Progress ยท materials, labor, after-photo IDs written to the case.
+
+ +
โœŽ
+
Request Signature
+
REQUEST SIGNATURE ยท Box Sign
+
Crew lead signs the completion certificate.
+ +
+
+
+
COMPLETED
+
โ‡„
+
Resolve Case
HTTPS โ†’ backend
+
status = Resolved ยท audit + before/after report.
+
DECLINED
+
โœ‰
+
Re-route
SEND NOTIFICATION
+
EXPIRED
+
โœ‰
+
Remind
SEND NOTIFICATION
+
CANCELLED
+
M
+
Hold
ADD METADATA
+
+
+ +
+
โœ‰
+
Notify Supervisor + Risk Management
+
SEND NOTIFICATION
+
Citizen portal status updated to โ€œResolvedโ€.
+
+ +
G
+
Audit-Ready Case Record
+
BOX GOVERNANCE
+
Retention / legal hold ยท full version history ยท activity log ยท access control.
+ +
+
+ + diff --git a/box-build/revised-workflow-diagram.html b/box-build/revised-workflow-diagram.html new file mode 100644 index 0000000000000000000000000000000000000000..a7744d859d29790d8a5d972e57cf3b951f92ece2 --- /dev/null +++ b/box-build/revised-workflow-diagram.html @@ -0,0 +1,206 @@ + + + + + +Pothole Operations โ€” Revised Workflow + + + +
+

Pothole Operations โ€” Revised Workflow

+
Backend computes the geospatial scoring ยท Box runs content, workflow, documents, signature & audit
+ +
+ Citizen portal + Our backend service + Box (native) + Decision + Field crew +
+ + +
Phase 1 โ€” Citizen Intake
+
+
Form fieldsName / contact ยท Location or address ยท Photo upload(s)
+
+
Citizen Submission Portal
+
Web form ยท mobile app ยท 311 integration
+ Submitted +
+
Auto-capturedGPS coordinates ยท Timestamp (UTC) ยท On-device AI size detection
+
+ +
+ + +
Phase 2 โ€” Backend Enrichment (at submission)
+
+
Severity inputsDamage size / depth ยท Road classification ยท Prior 311 notices ยท Weather forecast
+
+
Backend Service โ€” Enrich & Score
+
GPS โ†’ ward / district ยท Severity score ยท Weather risk ยท builds the complete JSON
+
+
Why backendGeometry, scoring & weather aren't AI tasks โ€” computed here, then handed to Box ready-to-use
+
+ +
+
Duplicate within 50โ€“100 m radius?
+
+ +
+
+
YES
+
+
Link to existing case
+
Boost severity ยท no new work order
+
+
+
+
NO
+
+
Create new case
+
Fresh case ID
+
+
+
+ +
+ + +
Phase 3 โ€” Box Storage & Routing
+
+
Lands in BoxPhoto + complete JSON โ†’ Incoming Reports folder
+
+
Upload to Box + Apply Metadata
+
Backend writes potholeCase metadata directly โ€” works even if Box AI / Extract is off
+
+
Box ExtractConfirms / maps JSON โ†’ metadata when enabled (optional, not required)
+
+ +
+
+
+
Case Folder Created + Metadata Applied
+
Box Relay ยท Potholes / Ward-XYZ / Case-ABC123
+ Under Review +
+
+ +
+ + +
Phase 4 โ€” Human Review & Dispatch
+
+
+
Ward Engineer Notified (Box Relay)
+
Folder link ยท photo ยท AI summary ยท nearby cases โ†’ approve / adjust severity / reject
+
+
+
+
+
+
Work Order Generated (Box Doc Gen)
+
Job Sheet PDF from case metadata โ€” no external system needed
+ Assigned +
+
+
+
+
+
Crew Completes Box Form (mobile)
+
Repair method ยท materials ยท labor hours ยท after-photo ยท Box Sign
+ In Progress +
+
+ +
+ + +
Phase 5 โ€” Closeout & Audit Record
+
+
NotificationsSupervisor + risk management ยท citizen portal updated to โ€œResolvedโ€
+
+
Audit-Ready Case Record
+
Box Governance โ€” retention / legal hold ยท full version history ยท activity log ยท access control
+ Resolved +
+
Citizen viewStatus checked in the portal โ€” no Box login required
+
+ + +
+ + diff --git a/box-build/seed-demo-cases.js b/box-build/seed-demo-cases.js new file mode 100644 index 0000000000000000000000000000000000000000..3fd34e7631ccab1261689ad34de7a4e076db2f66 --- /dev/null +++ b/box-build/seed-demo-cases.js @@ -0,0 +1,116 @@ +/** + * Seed demo pothole cases into Box for the PotholeIQ dashboard / Command Center. + * Runs the real backend pipeline (folders + files + potholeCase metadata on the case + * FOLDER only, incl. severity breakdown), then sets each case's lifecycle status. + * + * Usage: node box-build/seed-demo-cases.js [intakeFolderId] + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const bf = require(path.join(__dirname, '..', 'box-folders.js')); + +const TOKEN = process.argv[2]; +const INTAKE = process.argv[3] || '386202381022'; +if (!TOKEN) { console.error('Pass a fresh Box dev token as arg 1.'); process.exit(1); } + +const tmpl = { scope: 'enterprise', templateKey: 'potholeCase' }; +const now = () => new Date().toISOString(); +const daysAgo = (d) => new Date(Date.now() - d * 864e5).toISOString(); + +// ---- ward lookup from the bundled Philadelphia GeoJSON (point-in-polygon) ---- +const geo = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'ward-boundaries.geojson'), 'utf8')); +function pip(pt, poly){let [x,y]=pt,inside=false,n=poly.length,j=n-1;for(let i=0;iy)!=(yj>y))&&(x<(xj-xi)*(y-yi)/(yj-yi)+xi))inside=!inside;j=i;}return inside;} +function wardOf(lat,lng){for(const f of geo.features){const g=f.geometry,polys=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];for(const mp of polys){if(pip([lng,lat],mp[0]))return f.properties.ward_num||f.properties.ward||'?';}}return 'Unknown';} + +// ---- factor detail text (mirrors enrichment.js) ---- +const PN = s => s>=30?'3+ prior reports โ€” high prior notice / strong legal weight':s>=22?'2 prior reports โ€” notable prior notice':s>=12?'1 prior report found within 80 m':'No prior reports at this location'; +const TR = s => s>=25?'Critical infrastructure / high-traffic route (school, hospital, arterial)':s>=18?'Major arterial or collector road โ€” high traffic volume':s>=12?'Local collector street โ€” moderate traffic':'Residential / low-traffic street'; +const DM = s => s>=25?'Large โ€” bowl-shaped, severe damage affecting ride and safety':s>=15?'Medium โ€” noticeable damage, not yet vehicle-threatening':'Small โ€” initial / emerging surface defect'; +const WE = s => s>=10?'Freeze-thaw + heavy precipitation โ€” extreme expansion risk':s>=8?'Freeze-thaw cycle predicted โ€” high expansion risk':s>=7?'Heavy rain/snow forecast โ€” accelerated deterioration':s>=4?'Moderate precipitation expected โ€” moderate risk':'Dry / stable forecast โ€” minimal weather risk'; +const sizeFromDamage = s => s>=25?'Large':s>=15?'Medium':'Small'; +const riskFromWeather = s => s>=7?'high':s>=4?'medium':'low'; +const levelFromScore = s => s>=76?'Critical':s>=56?'High':s>=31?'Medium':'Low'; +// prior-notice score derived from the duplicate count, so "N prior reports" always == duplicateCount +const pnFromDup = d => d>=3?30:d>=2?22:d>=1?12:0; +// real demo photos (so the dashboard shows actual images, not text) +const DEMO_IMAGES = fs.readdirSync(path.join(__dirname, '..', 'demo pothole images')).filter(f => /\.jpe?g$/i.test(f)); + +// [name, lat, lng, prior, traffic, damage, weather, status, crew, dupCount] +const RAW = [ + ['City Hall', 39.9526,-75.1652, 22,25,25,10, 'under review','', 2], + ['Kensington Ave', 39.9905,-75.1190, 30,18,25, 8, 'assigned','Crew Alpha', 3], + ['Broad & Erie', 40.0090,-75.1490, 22,25,25, 7, 'in progress','Crew Bravo', 0], + ['Frankford Ave', 39.9690,-75.1300, 22,18,15, 7, 'under review','', 1], + ['Germantown Ave', 40.0330,-75.1730, 22,18,15, 6, 'assigned','Crew Charlie', 0], + ['Fairmount Ave', 39.9670,-75.1750, 12,25,15, 7, 'resolved','Crew Alpha', 0], + ['Frankford NE', 40.0220,-75.0840, 14,18,15,10, 'under review','', 0], + ['Lancaster Ave', 39.9612,-75.2090, 12,18, 6, 4, 'under review','', 0], + ['Manayunk Main St', 40.0260,-75.2230, 0,18,15, 4, 'assigned','Crew Delta', 0], + ['Point Breeze Ave', 39.9320,-75.1830, 12,12, 6, 7, 'resolved','Crew Bravo', 0], + ['Mount Airy', 40.0560,-75.1880, 0,18,15, 0, 'in progress','Crew Charlie',0], + ['Old City 2nd St', 39.9510,-75.1430, 12,12, 6, 4, 'under review','', 0], + ['Roxborough Ridge', 40.0440,-75.2230, 0,12, 6, 4, 'resolved','Crew Delta', 0], + ['Snyder Ave', 39.9210,-75.1700, 0, 6, 6, 4, 'rejected','', 0], + ['Bustleton Ave', 40.0600,-75.0500, 0, 6, 6, 0, 'rejected','', 0], +]; + +const CASES = RAW.map(([name,lat,lng,pn0,tr,dm,we,status,crew,dup],i)=>{ + const pn = pnFromDup(dup); // keep prior-notice consistent with duplicate count + const score = pn+tr+dm+we; + return { + suffix: String(i+1).padStart(2,'0'), name, lat, lng, status, crew, + ward: String(wardOf(lat,lng)), + address: `${name}, Philadelphia, PA`, + size: sizeFromDamage(dm), score, level: levelFromScore(score), + dupCount: dup, linked: dup>0 ? [`PHX-LEGACY-${100+i}`,`PHX-LEGACY-${200+i}`].slice(0,dup) : [], + submittedAt: daysAgo((i%10)+1), + bd: { + priorNotice:{score:pn,detail:PN(pn)}, traffic:{score:tr,detail:TR(tr)}, + damage:{score:dm,detail:DM(dm)}, weather:{score:we,detail:WE(we),riskLevel:riskFromWeather(we)}, + }, + }; +}); + +async function uploadFile(name, bytes){ + const fd=new FormData(); + fd.append('attributes',JSON.stringify({name,parent:{id:INTAKE}})); + fd.append('file',new Blob([bytes]),name); + const r=await fetch('https://upload.box.com/api/2.0/files/content',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}).then(x=>x.json()); + return r.entries[0].id; +} +const md = (id) => fetch(`https://api.box.com/2.0/folders/${id}/metadata/enterprise/potholeCase`,{headers:{Authorization:`Bearer ${TOKEN}`}}).then(r=>r.json()); + +(async () => { + const me = await fetch('https://api.box.com/2.0/users/me?fields=name,login',{headers:{Authorization:`Bearer ${TOKEN}`}}).then(r=>r.json()); + if(!me.login){console.error('Token invalid:',JSON.stringify(me));process.exit(1);} + console.log('Auth OK:', me.name); + + let dist={Critical:0,High:0,Medium:0,Low:0}; + for(const c of CASES){ + const caseId=`PHX-DEMO-${c.suffix}`; + const report={caseId,submittedAt:c.submittedAt,address:c.address,location:{lat:c.lat,lng:c.lng,source:'gps'},aiResult:c.size,aiPrediction:c.size,aiConfidence:0.9,aiReviewStatus:'auto_confirmed'}; + const enrichment={severityScore:c.score,severityLevel:c.level,enrichedAt:c.submittedAt, + district:{district:`District ${c.ward}`,ward:c.ward,maintenanceZone:`Zone-${c.ward}`}, + duplicate:{isDuplicate:c.dupCount>0,duplicateCount:c.dupCount,linkedCaseIds:c.linked,effectiveSeverityBoost:c.dupCount*4}, + severityBreakdown:{priorNotice:{...c.bd.priorNotice,max:30},traffic:{...c.bd.traffic,max:25},damage:{...c.bd.damage,max:25},weather:{...c.bd.weather,max:10}}, + report}; + const imgBytes=fs.readFileSync(path.join(__dirname,'..','demo pothole images',DEMO_IMAGES[(parseInt(c.suffix,10)-1)%DEMO_IMAGES.length])); + const photoFileId=await uploadFile(`${caseId}_photo.jpg`,imgBytes); + const sidecarFileId=await uploadFile(`${caseId}_metadata.json`,JSON.stringify(report,null,2)); + const res=await bf.organizeInBox({caseId,ward:c.ward,district:`District ${c.ward}`,photoFileId,sidecarFileId,enrichment,reportPayload:report,rootFolderId:INTAKE,metadataTemplate:tmpl,token:TOKEN,onStep:()=>{}}); + // set lifecycle status (organizeInBox defaults to 'under review') + if(c.status!=='under review'){ + const patch={status:c.status}; + if(c.crew)patch.assignedCrew=c.crew; + if(c.status==='resolved'){patch.resolvedAt=now();patch.supervisorWardEngineer='Eng. R. Diaz';} + await bf.applyFolderMetadata(res.caseFolderId,patch,TOKEN,{template:tmpl}); + } else if(c.crew){ + await bf.applyFolderMetadata(res.caseFolderId,{assignedCrew:c.crew},TOKEN,{template:tmpl}); + } + dist[c.level]++; + console.log(` ${caseId} Ward ${c.ward.padStart(2)} ${c.level.padEnd(8)} ${String(c.score).padStart(3)} ${c.status}`); + } + console.log('\nSeeded',CASES.length,'cases | severity:',JSON.stringify(dist)); + console.log('Open: http://localhost:3030/command-center.html'); +})().catch(e=>{console.error('ERROR',e);process.exit(1);}); diff --git a/box-build/wf1-revised-diagram.html b/box-build/wf1-revised-diagram.html new file mode 100644 index 0000000000000000000000000000000000000000..644d7d89cce990ae83dfeb77ce5a47d5af586f0c --- /dev/null +++ b/box-build/wf1-revised-diagram.html @@ -0,0 +1,119 @@ + + + + + +Workflow One โ€” Revised (Intake โ†’ Dispatch) + + + +
+

Workflow One โ€” Revised

+
Backend owns folders + metadata ยท WF1 = human workflow only ยท + Work Order added
+ +
+ Trigger + Box AI + Metadata / Action + Notification + Doc Gen + Remove +
+ + +
โšก Trigger โ€” Metadata Event
potholeCase ยท status = under review  (fires after the backend applies metadata โ€” loop-safe, no race)
+
โ›” CHANGED from "File Uploaded โ†’ Incoming Detections" (that raced the backend & fired before metadata existed)
+
+ + +
Default Standard Extract Agent
DELETE โ€” backend already wrote the metadata; this node was halting the flow
+
+ + +
๐Ÿค– Box Agent โ€” AI Summary
2โ€“3 sentence case summary โ†’ write to aiSummary
+
+ + +
Conditional Split โ€” duplicateStatus = "duplicate "?
+
+ +
+
+
Branch 1 โ€” DUPLICATE
+
Move โ†’ duplicates/
DELETE โ€” backend already placed the file
+
+
๐Ÿ”” Send Notification
Alert ward engineer: linked to existing cluster ({linkedCaseIds})
+
+
โ›” End โ€” no work order (it's a duplicate)
+
+ +
+
Else โ€” NEW CASE
+
Create "Case-{caseId}" folder
DELETE โ€” backend creates Potholes/{ward}/Case-{id}
+
+
๐Ÿ”” Send Notification โ†’ ward engineer
New case {caseId} ยท {severityLevel} ยท {aiSummary} + folder link
+
+
โœ… Approval Task
"Approve, adjust severity, or reject" โ†’ ward engineer
+
+
+ +
New case ยท on task result
+
Conditional Split โ€” Task Approved?
+
+ +
+
+
Approved
+
๐Ÿท๏ธ Add Metadata
status = assigned
+
+
๐Ÿ“„ Generate Document โ€” WORK ORDER โ˜…
Template: Pothole Repair Work Order (DocGen) ยท save PDF to case folder
Tags from metadata by caseId: caseId ยท severityLevel ยท address ยท ward ยท assignedCrew
+
+
๐Ÿท๏ธ Add Metadata
workOrderId = {generated doc name / id}
+
+
โ›” End โ€” case Assigned, work order filed
+
+
+
Rejected
+
๐Ÿท๏ธ Add Metadata
status = rejected
+
+
๐Ÿ”” Send Notification
"Case {caseId} ({severityLevel}) was rejected"
+
+
โ›” End โ€” case Rejected
+
+
+ +
+ โ˜… The work order you flagged: add a Generate Document node in the Approved branch. + Crew may not be assigned yet at approval, so assignedCrew can be blank here and filled at the deploy-crew step. +
+
+ + diff --git a/box-folders.js b/box-folders.js new file mode 100644 index 0000000000000000000000000000000000000000..0dedcfc32079a847f1659db943a9dfbfb8f07a72 --- /dev/null +++ b/box-folders.js @@ -0,0 +1,656 @@ +/** + * box-folders.js + * Box folder hierarchy creation, file moving, and metadata writing + * for the pothole operations enrichment pipeline. + * + * Creates structure: + * [Root Incoming Folder] + * \- Potholes/ + * \- Ward-29/ + * \- Case-PHX-1234/ + * |- PHX-1234_photo.jpg + * \- PHX-1234_metadata.json + * + * Uses Box generic "properties" metadata for compatibility and can also write + * to a named Box metadata template when one is configured. + */ + +'use strict'; + +const BOX_API = 'https://api.box.com/2.0'; +const BOX_UPLOAD = 'https://upload.box.com/api/2.0'; +const boxWorkflowModule = (typeof module !== 'undefined' && module.exports) + ? require('./box-workflow.js') + : null; + +async function boxRequest(method, path, token, body = null) { + const opts = { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }; + const res = await fetch(`${BOX_API}${path}`, opts); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, status: res.status, data }; +} + +function normalizeTemplateConfig(config = {}) { + return { + scope: String(config?.scope || '').trim(), + templateKey: String(config?.templateKey || '').trim(), + }; +} + +function shouldApplyCustomTemplate(config = {}) { + const normalized = normalizeTemplateConfig(config); + if (!normalized.scope || !normalized.templateKey) return false; + return !(normalized.scope === 'global' && normalized.templateKey === 'properties'); +} + +// --- Structured metadata template projection ------------------------------- +// A Box metadata template REJECTS unknown keys, wrong value types (float/date), +// and enum values that don't exactly match a stored option key. The backend's +// rich review object can't be sent as-is, so we project it onto the live schema: +// pick the right source value per field, coerce its type, and snap enum values +// to the exact option key the template stores (case/whitespace insensitive). + +const __templateSchemaCache = new Map(); + +async function getTemplateSchema(scope, templateKey, token) { + const cacheKey = `${scope}/${templateKey}`; + if (__templateSchemaCache.has(cacheKey)) return __templateSchemaCache.get(cacheKey); + const { ok, data } = await boxRequest('GET', `/metadata_templates/${scope}/${templateKey}/schema`, token); + const schema = ok && Array.isArray(data.fields) ? data : null; + __templateSchemaCache.set(cacheKey, schema); + return schema; +} + +// template field key -> ordered source keys in the review kvMap (first non-empty wins) +const TEMPLATE_KEY_ALIASES = { + severityScore0100: ['severityScore0100', 'severityScore'], + weatherRisk: ['weatherRisk', 'weatherStatus', 'weatherRiskLevel'], + supervisorWardEngineer: ['supervisorWardEngineer', 'supervisor'], + caseFolderId: ['caseFolderId', 'boxCaseFolderId', 'boxFolderId'], + linkedCaseIds: ['linkedCaseIds', 'linkedCases'], + status: ['status', 'workflowStatus'], +}; + +// Per-field input-value remaps applied BEFORE matching against the schema options. +// Needed where the source vocabulary differs from the template's option set. +const ENUM_VALUE_ALIASES = { + weatherRisk: { high: 'large', severe: 'large' }, // template has no "High" option (Low/Medium/Large) +}; + +function pickSourceValue(kvMap, templateKey) { + const aliases = TEMPLATE_KEY_ALIASES[templateKey] || [templateKey]; + for (const alias of aliases) { + const v = kvMap[alias]; + if (v !== undefined && v !== null && String(v).trim() !== '') return v; + } + return undefined; +} + +function matchEnumOption(value, options = []) { + const norm = (s) => String(s).trim().toLowerCase(); + const target = norm(value); + const opt = options.find((o) => norm(o.key) === target); + return opt ? opt.key : null; // return EXACT stored key (preserves casing/trailing spaces) +} + +function coerceForField(field, rawValue) { + if (rawValue === undefined || rawValue === null) return undefined; + const sval = String(rawValue).trim(); + if (sval === '') return undefined; + switch (field.type) { + case 'float': { + const n = Number(sval); + return Number.isFinite(n) ? n : undefined; + } + case 'date': { + const d = new Date(sval); + if (Number.isNaN(d.getTime())) return undefined; + return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); // RFC3339, no milliseconds + } + case 'enum': + case 'multiSelect': { + const valAlias = (ENUM_VALUE_ALIASES[field.key] || {})[sval.toLowerCase()]; + return matchEnumOption(valAlias || sval, field.options) || undefined; + } + default: + return sval; + } +} + +function projectToTemplateSchema(kvMap, schema) { + const out = {}; + for (const field of schema.fields || []) { + const coerced = coerceForField(field, pickSourceValue(kvMap, field.key)); + if (coerced !== undefined) out[field.key] = coerced; + } + return out; +} + +async function getOrCreateFolder(parentId, name, token) { + console.log(`[BoxFolders] getOrCreateFolder("${name}") in parent ${parentId}`); + const { ok, status, data } = await boxRequest('POST', '/folders', token, { + name, + parent: { id: parentId }, + }); + + if (ok) { + console.log(`[BoxFolders] Folder created: "${name}" -> id=${data.id}`); + return data.id; + } + + if (status === 409) { + const existingId = data.context_info?.conflicts?.[0]?.id || null; + if (existingId) { + console.log(`[BoxFolders] Folder already exists: "${name}" -> id=${existingId}`); + return existingId; + } + + const items = await boxRequest('GET', `/folders/${parentId}/items?limit=200`, token); + const match = (items.data?.entries || []).find((entry) => entry.type === 'folder' && entry.name === name); + if (match) { + console.log(`[BoxFolders] Folder found via listing: "${name}" -> id=${match.id}`); + return match.id; + } + + throw new Error(`Could not resolve existing folder "${name}" in parent ${parentId}`); + } + + throw new Error(`Create folder "${name}" failed (${status}): ${data.message || JSON.stringify(data)}`); +} + +async function moveFile(fileId, destFolderId, token) { + console.log(`[BoxFolders] moveFile fileId=${fileId} -> folder ${destFolderId}`); + const { ok, status, data } = await boxRequest('PUT', `/files/${fileId}`, token, { + parent: { id: destFolderId }, + }); + if (!ok) throw new Error(`Move file ${fileId} failed (${status}): ${data.message || ''}`); + console.log( + `[BoxFolders] File moved. New path: ${(data.path_collection?.entries || []).map((entry) => entry.name).join('/')}` + ); + return data; +} + +async function uploadGeneratedFile(caseFolderId, fileName, contents, mimeType, token) { + const blob = new Blob([contents], { type: mimeType }); + const attributes = JSON.stringify({ name: fileName, parent: { id: caseFolderId } }); + + const form = new FormData(); + form.append('attributes', attributes); + form.append('file', blob, fileName); + + const res = await fetch(`${BOX_UPLOAD}/files/content`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(`Generated file upload failed (${res.status}): ${err.message || ''}`); + } + + const data = await res.json(); + console.log(`[BoxFolders] Generated file uploaded: ${fileName} -> id=${data.entries?.[0]?.id}`); + return data.entries?.[0]?.id || null; +} + +async function uploadEnrichedSidecar(caseFolderId, caseId, enrichedPayload, token) { + const fileName = `${caseId}_enriched_metadata.json`; + const contents = JSON.stringify(enrichedPayload, null, 2); + return uploadGeneratedFile(caseFolderId, fileName, contents, 'application/json', token); +} + +function buildReviewSummary(caseId, reportPayload, enrichment, reviewMetadata, folderPath) { + const address = reportPayload?.address || 'Address not available'; + const district = reviewMetadata.district || 'Unknown'; + const ward = reviewMetadata.ward || 'Unknown'; + const zone = reviewMetadata.maintenanceZone || 'Unknown'; + const severity = `${reviewMetadata.severityLevel || 'Unknown'} (${reviewMetadata.severityScore || 'n/a'}/100)`; + const road = reviewMetadata.trafficDetail || 'Road classification unavailable'; + const weather = reviewMetadata.weatherDetail || 'Weather risk unavailable'; + const weatherStatus = reviewMetadata.weatherStatus || reviewMetadata.weatherRiskLevel || 'Unknown'; + const potholeSize = reviewMetadata.potholeSize || reviewMetadata.aiResult || reviewMetadata.aiPrediction || 'Unknown'; + const aiConfidence = reviewMetadata.aiConfidence !== '' + ? `${reviewMetadata.aiConfidence}%` + : 'Unavailable'; + const visualVerification = reviewMetadata.needsVisualVerification === 'true' + ? 'Yes - AI was not confident this is a pothole. Send image for Box-side visual verification.' + : 'No - base model confidence was sufficient.'; + const duplicate = reviewMetadata.isDuplicate === 'true' + ? `Yes (${reviewMetadata.duplicateCount || 0} related case(s), linked: ${reviewMetadata.linkedCases || 'none'})` + : 'No'; + const priorNotice = reviewMetadata.priorNoticeDetail || 'No prior notice detail'; + const damage = reviewMetadata.damageDetail || 'Damage detail unavailable'; + const reporterNote = reportPayload?.description || reportPayload?.notes || 'No additional citizen notes.'; + + return [ + `POTHOLE REVIEW SUMMARY`, + ``, + `Case ID: ${caseId}`, + `Address: ${address}`, + `Ward: ${ward}`, + `District: ${district}`, + `Maintenance Zone: ${zone}`, + `Box Path: ${folderPath}`, + ``, + `SUPERVISOR REVIEW DATA`, + `Severity: ${severity}`, + `Pothole Size: ${potholeSize}`, + `Road Context: ${road}`, + `Road Classification: ${reviewMetadata.roadClassification || 'unknown'}`, + `Weather Risk: ${weather}`, + `Weather Status: ${weatherStatus}`, + `AI Confidence: ${aiConfidence}`, + `Needs Visual Verification: ${visualVerification}`, + `Duplicate Cluster: ${duplicate}`, + `Prior Notice: ${priorNotice}`, + `Damage Assessment: ${damage}`, + ``, + `REPORTER NOTES`, + `${reporterNote}`, + ``, + `REVIEW INSTRUCTION`, + `Check the photo, summary, severity, duplicate context, ward/district/zone, and decide the next operational step.`, + ``, + `Generated: ${reviewMetadata.enrichedAt || new Date().toISOString()}`, + ].join('\n'); +} + +async function uploadReviewSummary(caseFolderId, caseId, reviewSummary, token, options = {}) { + const fileName = options.needsVisualVerification + ? `${caseId}_review_summary_uncertain.txt` + : `${caseId}_review_summary.txt`; + return uploadGeneratedFile(caseFolderId, fileName, reviewSummary, 'text/plain', token); +} + +function buildFolderWorkflowMetadata(reportPayload, enrichment, context) { + if (typeof window !== 'undefined' && window.BoxWorkflow?.buildEnrichedWorkflow) { + return window.BoxWorkflow.buildEnrichedWorkflow(reportPayload, enrichment, context); + } + if (boxWorkflowModule?.buildEnrichedWorkflow) { + return boxWorkflowModule.buildEnrichedWorkflow(reportPayload, enrichment, context); + } + + return { + template: 'pothole_box_ops_v1', + currentStage: 'supervisor_review', + currentStageLabel: 'Supervisor Review', + status: 'Under Review', + queue: 'Supervisor Review', + nextAction: 'Supervisor approve the case and send it to crew routing', + priority: 'normal', + boxPath: context.folderPath || '', + lastUpdatedAt: enrichment?.enrichedAt || new Date().toISOString(), + }; +} + +function flattenFolderWorkflow(workflow) { + if (typeof window !== 'undefined' && window.BoxWorkflow?.flattenWorkflowForBox) { + return window.BoxWorkflow.flattenWorkflowForBox(workflow); + } + if (boxWorkflowModule?.flattenWorkflowForBox) { + return boxWorkflowModule.flattenWorkflowForBox(workflow); + } + + return { + workflowTemplate: workflow.template || 'pothole_box_ops_v1', + workflowStage: workflow.currentStage || '', + workflowStageLabel: workflow.currentStageLabel || '', + workflowStatus: workflow.status || '', + workflowQueue: workflow.queue || '', + workflowNextAction: workflow.nextAction || '', + workflowPriority: workflow.priority || '', + workflowBoxPath: workflow.boxPath || '', + workflowLastUpdatedAt: workflow.lastUpdatedAt || '', + }; +} + +async function writeMetadataDocument(itemType, itemId, kvMap, token, config = {}, options = {}) { + const { stringifyValues = true } = options; + const normalized = normalizeTemplateConfig(config); + const scope = normalized.scope || 'global'; + const templateKey = normalized.templateKey || 'properties'; + const path = `/${itemType}/${itemId}/metadata/${scope}/${templateKey}`; + const payload = stringifyValues + ? Object.fromEntries(Object.entries(kvMap).map(([key, value]) => [key, String(value ?? '')])) + : { ...kvMap }; + if (!Object.keys(payload).length) { + console.log(`[BoxFolders] No template-valid fields to write for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`); + return; + } + + const { ok, status } = await boxRequest('POST', path, token, payload); + if (ok) { + console.log(`[BoxFolders] Metadata applied to ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`); + return; + } + + if (status === 409) { + const ops = Object.entries(payload).map(([key, value]) => ({ op: 'add', path: `/${key}`, value })); + const patchRes = await fetch(`${BOX_API}${path}`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json-patch+json', + }, + body: JSON.stringify(ops), + }); + if (!patchRes.ok) { + const patchErr = await patchRes.json().catch(() => ({})); + throw new Error(`Metadata patch failed (${patchRes.status}): ${patchErr.message || ''}`.trim()); + } + console.log(`[BoxFolders] Metadata patched (${patchRes.status}) for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`); + return; + } + + throw new Error(`Metadata write failed (${status}) for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`); +} + +async function applyMetadata(itemType, itemId, kvMap, token, options = {}) { + // Generic freeform properties: accepts any key, values as strings. + await writeMetadataDocument(itemType, itemId, kvMap, token, { scope: 'global', templateKey: 'properties' }); + + if (shouldApplyCustomTemplate(options.template)) { + const { scope, templateKey } = normalizeTemplateConfig(options.template); + const schema = await getTemplateSchema(scope, templateKey, token); + if (!schema) { + console.warn(`[BoxFolders] Schema unavailable for ${scope}/${templateKey}; skipping structured template.`); + return; + } + const projected = projectToTemplateSchema(kvMap, schema); + await writeMetadataDocument(itemType, itemId, projected, token, options.template, { stringifyValues: false }); + } +} + +async function applyFolderMetadata(folderId, kvMap, token, options = {}) { + return applyMetadata('folders', folderId, kvMap, token, options); +} + +async function applyFileMetadata(fileId, kvMap, token, options = {}) { + return applyMetadata('files', fileId, kvMap, token, options); +} + +function classifyRoadContext(detail = '') { + const normalized = String(detail || '').toLowerCase(); + if (normalized.includes('critical infrastructure') || normalized.includes('high-traffic')) return 'critical'; + if (normalized.includes('major arterial') || normalized.includes('high traffic')) return 'high'; + if (normalized.includes('residential') || normalized.includes('low-traffic')) return 'low'; + if (normalized.includes('moderate')) return 'medium'; + return 'unknown'; +} + +function isUncertainPotholeCase(reportPayload = {}) { + const reviewStatus = String(reportPayload?.aiReviewStatus || '').toLowerCase().trim(); + const aiResult = String(reportPayload?.aiResult || '').trim(); + return reviewStatus === 'manual_review' && !aiResult; +} + +function buildReviewMetadata(caseId, ward, district, folderPath, enrichment, workflowFields) { + const breakdown = enrichment?.severityBreakdown || {}; + const priorNotice = breakdown.priorNotice || {}; + const traffic = breakdown.traffic || {}; + const damage = breakdown.damage || {}; + const weather = breakdown.weather || {}; + const duplicate = enrichment?.duplicate || {}; + const districtData = enrichment?.district || {}; + const reportPayload = enrichment?.report || {}; + const aiReviewStatus = String(reportPayload.aiReviewStatus || '').trim(); + const aiResult = String(reportPayload.aiResult || '').trim(); + const aiPrediction = String(reportPayload.aiPrediction || '').trim(); + const aiConfidenceRaw = reportPayload.aiConfidence; + const aiConfidence = Number.isFinite(Number(aiConfidenceRaw)) + ? Math.round(Number(aiConfidenceRaw) * 100) + : ''; + const needsVisualVerification = isUncertainPotholeCase(reportPayload); + const duplicateStatus = duplicate.isDuplicate ? 'duplicate' : 'new'; + const weatherStatus = weather.riskLevel || (weather.detail ? 'available' : 'pending'); + const potholeSize = aiResult || aiPrediction || String(reportPayload.potholeSize || '').trim(); + + return { + caseId, + submittedAt: reportPayload.submittedAt || '', + address: reportPayload.address || '', + areaLabel: reportPayload.areaLabel || '', + latitude: reportPayload.location?.lat ?? '', + longitude: reportPayload.location?.lng ?? '', + locationSource: reportPayload.location?.source || reportPayload.locationSource || '', + district: districtData.district || district || 'Unknown', + ward: districtData.ward || ward || 'Unknown', + maintenanceZone: districtData.maintenanceZone || 'Zone-X', + duplicateStatus, + severityScore: enrichment?.severityScore ?? '', + severityLevel: enrichment?.severityLevel || '', + priorNoticeScore: priorNotice.score ?? '', + priorNoticeDetail: priorNotice.detail || '', + trafficScore: traffic.score ?? '', + trafficDetail: traffic.detail || '', + roadClassification: classifyRoadContext(traffic.detail), + damageScore: damage.score ?? '', + damageDetail: damage.detail || '', + weatherScore: weather.score ?? '', + weatherDetail: weather.detail || '', + weatherRiskLevel: weather.riskLevel || '', + weatherStatus, + potholeSize, + aiResult, + aiPrediction, + aiReviewStatus, + aiReviewMessage: reportPayload.aiReviewMessage || '', + aiConfidence, + needsVisualVerification: needsVisualVerification ? 'true' : 'false', + duplicateCount: duplicate.duplicateCount ?? '', + duplicateBoost: duplicate.effectiveSeverityBoost ?? '', + isDuplicate: duplicate.isDuplicate ? 'true' : 'false', + linkedCases: (duplicate.linkedCaseIds || []).join(', '), + boxPath: folderPath, + enrichedAt: enrichment?.enrichedAt || '', + enrichedBy: 'ops-console-v2', + workflowStage: 'supervisor_review', + workflowStatus: 'Under Review', + workflowNextAction: 'Supervisor review pending', + workflowPriority: enrichment?.severityLevel === 'Critical' ? 'urgent' + : enrichment?.severityLevel === 'High' ? 'high' + : enrichment?.severityLevel === 'Medium' ? 'medium' + : 'normal', + ...workflowFields, + }; +} + +/** + * @param {object} opts + * @param {string} opts.caseId + * @param {string} opts.ward + * @param {string} opts.district + * @param {string} [opts.photoFileId] + * @param {string} [opts.sidecarFileId] + * @param {object} opts.enrichment + * @param {object} opts.reportPayload + * @param {string} opts.rootFolderId + * @param {string} opts.token + * @param {(label: string, status: string) => void} [opts.onStep] + * @returns {Promise<{ folderPath: string, caseFolderId: string, potholesId: string, wardId: string, enrichedFileId: string|null, reviewSummaryFileId: string|null }>} + */ +async function organizeInBox(opts = {}) { + if (typeof window !== 'undefined') { + const response = await fetch('/api/box/organize-case', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(opts), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(payload?.error || `Box organize failed with HTTP ${response.status}`); + } + + return payload?.result || null; + } + + const { + caseId, + ward, + district, + photoFileId, + sidecarFileId, + enrichment, + reportPayload, + rootFolderId, + metadataTemplate = {}, + token, + onStep = () => {}, + } = opts; + const step = (label, status) => onStep(label, status); + const metadataOptions = { template: normalizeTemplateConfig(metadataTemplate) }; + + step('Creating Potholes/ root folder...', 'running'); + const potholesId = await getOrCreateFolder(rootFolderId, 'Potholes', token); + step(`Potholes/ folder ready (id: ${potholesId})`, 'done'); + + const wardSlug = (ward || 'Ward-Unknown').replace(/\s+/g, '-'); + step(`Creating ${wardSlug}/ subfolder...`, 'running'); + const wardId = await getOrCreateFolder(potholesId, wardSlug, token); + step(`${wardSlug}/ ready (id: ${wardId})`, 'done'); + + const caseFolderName = `Case-${caseId}`; + step(`Creating case folder ${caseFolderName}/...`, 'running'); + const caseFolderId = await getOrCreateFolder(wardId, caseFolderName, token); + step(`Case folder created (id: ${caseFolderId})`, 'done'); + + const folderPath = `Potholes/${wardSlug}/${caseFolderName}`; + + if (photoFileId) { + step('Moving photo into case folder...', 'running'); + await moveFile(photoFileId, caseFolderId, token).catch((error) => { + step(`Photo move warning: ${error.message}`, 'warn'); + }); + step('Photo moved OK', 'done'); + } + + if (sidecarFileId) { + step('Moving original metadata JSON...', 'running'); + await moveFile(sidecarFileId, caseFolderId, token).catch((error) => { + step(`Sidecar move warning: ${error.message}`, 'warn'); + }); + step('Metadata JSON moved OK', 'done'); + } + + const workflow = buildFolderWorkflowMetadata(reportPayload, enrichment, { folderPath, rootFolderId }); + const workflowFields = flattenFolderWorkflow(workflow); + const reviewMetadata = buildReviewMetadata(caseId, ward, district, folderPath, enrichment, workflowFields); + const reviewSummary = buildReviewSummary(caseId, reportPayload, enrichment, reviewMetadata, folderPath); + const needsVisualVerification = isUncertainPotholeCase(reportPayload); + + step('Uploading enriched metadata JSON...', 'running'); + const enrichedPayload = { + caseId, + submittedAt: reportPayload.submittedAt || '', + enrichedAt: enrichment.enrichedAt, + address: reportPayload.address || '', + areaLabel: reportPayload.areaLabel || '', + location: reportPayload.location || null, + locationSource: reportPayload.location?.source || reportPayload.locationSource || '', + severityScore: enrichment.severityScore, + severityLevel: enrichment.severityLevel, + district: enrichment.district?.district || district, + ward: enrichment.district?.ward || ward, + maintenanceZone: enrichment.district?.maintenanceZone || 'Zone-X', + duplicateStatus: enrichment.duplicate?.isDuplicate ? 'duplicate' : 'new', + isDuplicate: enrichment.duplicate?.isDuplicate, + duplicateCount: enrichment.duplicate?.duplicateCount ?? 0, + duplicateBoost: enrichment.duplicate?.effectiveSeverityBoost ?? 0, + linkedCases: enrichment.duplicate?.linkedCaseIds?.join(', ') || '', + weatherStatus: enrichment.severityBreakdown?.weather?.riskLevel || 'pending', + potholeSize: reportPayload.aiResult || reportPayload.aiPrediction || '', + aiResult: reportPayload.aiResult || '', + aiPrediction: reportPayload.aiPrediction || '', + aiReviewStatus: reportPayload.aiReviewStatus || '', + aiReviewMessage: reportPayload.aiReviewMessage || '', + priorNoticeScore: enrichment.severityBreakdown?.priorNotice?.score ?? '', + priorNoticeDetail: enrichment.severityBreakdown?.priorNotice?.detail || '', + trafficScore: enrichment.severityBreakdown?.traffic?.score ?? '', + trafficDetail: enrichment.severityBreakdown?.traffic?.detail || '', + roadClassification: classifyRoadContext(enrichment.severityBreakdown?.traffic?.detail), + damageScore: enrichment.severityBreakdown?.damage?.score ?? '', + damageDetail: enrichment.severityBreakdown?.damage?.detail || '', + weatherScore: enrichment.severityBreakdown?.weather?.score ?? '', + weatherDetail: enrichment.severityBreakdown?.weather?.detail || '', + weatherRiskLevel: enrichment.severityBreakdown?.weather?.riskLevel || '', + weatherRisk: enrichment.weather + ? `${enrichment.severityBreakdown?.weather?.riskLevel || 'unknown'} - ${enrichment.severityBreakdown?.weather?.detail}` + : 'Unavailable', + boxPath: folderPath, + workflow, + ...workflowFields, + severityBreakdown: enrichment.severityBreakdown, + report: reportPayload, + }; + const enrichedFileId = await uploadEnrichedSidecar(caseFolderId, caseId, enrichedPayload, token).catch((error) => { + step(`Enriched sidecar warning: ${error.message}`, 'warn'); + return null; + }); + step('Enriched metadata JSON uploaded OK', 'done'); + + step('Uploading human-readable review summary...', 'running'); + const reviewSummaryFileId = await uploadReviewSummary(caseFolderId, caseId, reviewSummary, token, { + needsVisualVerification, + }).catch((error) => { + step(`Review summary warning: ${error.message}`, 'warn'); + return null; + }); + step('Review summary uploaded OK', 'done'); + + step('Applying Box folder metadata template...', 'running'); + await applyFolderMetadata(caseFolderId, reviewMetadata, token, metadataOptions).catch((error) => { + step(`Metadata warning: ${error.message}`, 'warn'); + }); + step('Box metadata template applied OK', 'done'); + + // NOTE: only the case FOLDER carries the structured potholeCase template (it's the + // canonical case record). Helper files get freeform properties only ({} = no custom + // template) so the App's metadata view counts 1 record per case, not 3. + if (enrichedFileId) { + step('Applying Box metadata to enriched review file...', 'running'); + await applyFileMetadata(enrichedFileId, reviewMetadata, token, {}).catch((error) => { + step(`Review file metadata warning: ${error.message}`, 'warn'); + }); + step('Review file metadata applied OK', 'done'); + } + + if (reviewSummaryFileId) { + step('Applying Box metadata to review summary file...', 'running'); + await applyFileMetadata(reviewSummaryFileId, reviewMetadata, token, {}).catch((error) => { + step(`Review summary metadata warning: ${error.message}`, 'warn'); + }); + step('Review summary metadata applied OK', 'done'); + } + + console.log(`[BoxFolders] organizeInBox complete -> ${folderPath}`); + return { folderPath, caseFolderId, wardId, potholesId, enrichedFileId, reviewSummaryFileId }; +} + +if (typeof window !== 'undefined') { + window.BoxFolders = { organizeInBox, getOrCreateFolder, moveFile, applyFolderMetadata }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + organizeInBox, + getOrCreateFolder, + moveFile, + applyFolderMetadata, + applyFileMetadata, + getTemplateSchema, + projectToTemplateSchema, + coerceForField, + matchEnumOption, + }; +} diff --git a/box-service.js b/box-service.js new file mode 100644 index 0000000000000000000000000000000000000000..d9bcc1c61d2d7aa62f8f1fc74fc0971459ac27a2 --- /dev/null +++ b/box-service.js @@ -0,0 +1,1318 @@ +'use strict'; + +const { + buildInitialWorkflow, + flattenWorkflowForBox, +} = require('./box-workflow.js'); +const { + organizeInBox, + applyFolderMetadata, + getOrCreateFolder, +} = require('./box-folders.js'); + +const BOX_API_BASE = 'https://api.box.com/2.0'; +const BOX_UPLOAD_URL = 'https://upload.box.com/api/2.0/files/content'; +const BOX_OAUTH_TOKEN_URL = 'https://api.box.com/oauth2/token'; +const DEFAULT_GIS_BOUNDARY_URL = 'https://opendata.arcgis.com/datasets/philly-political-wards.geojson'; +const TOKEN_REFRESH_SKEW_MS = 2 * 60 * 1000; + +function nowIso() { + return new Date().toISOString(); +} + +function normalizeString(value) { + return String(value ?? '').trim(); +} + +// The citizen contact field is free-text ("email or phone" and may include a name), +// so pull the email out of whatever string (or JSON blob) we're handed. Phone-only +// values yield '' and never trigger a notify. +function extractEmail(value) { + const match = String(value || '').match(/[^\s@<>"'(),;:]+@[^\s@<>"'(),;:]+\.[^\s@<>"'(),;:]+/); + return match ? match[0].replace(/[.,;:>)\]]+$/, '') : ''; +} + +// Friendly, citizen-facing copy for each status โ€” the body of the Box status note. +const CITIZEN_STATUS_COPY = { + 'under review': 'Your report is being reviewed by our team. We will update you once a decision is made.', + submitted: 'Thanks โ€” your report has been received and logged. We will review it shortly.', + assigned: 'Good news! Your report has been approved and a repair crew has been assigned. A work order has been created.', + 'in progress': 'A crew is now actively working on the repair at the reported location.', + resolved: 'The pothole has been repaired and your case is now closed. Thank you for helping keep our roads safe.', + rejected: 'After review, this report was closed without a repair being dispatched. See the notes in your case for details.', +}; + +function buildCitizenStatusNote(caseId, statusLabel, caseRecord = null) { + const report = caseRecord?.report || {}; + const key = String(statusLabel || '').trim().toLowerCase(); + const message = CITIZEN_STATUS_COPY[key] || `Your report status has been updated to "${statusLabel}".`; + const lines = [ + `Pothole Report โ€” Status Update`, + ``, + `Case Reference: ${caseId}`, + `Current Status: ${statusLabel}`, + report.address ? `Location: ${report.address}` : '', + `Updated: ${new Date().toLocaleString()}`, + ``, + message, + ``, + `You can view live progress any time on the Track Your Report page using your Case Reference number.`, + ]; + return lines.filter((line) => line !== '').join('\n'); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseRetryAfter(headers) { + const raw = headers?.get?.('retry-after'); + if (!raw) return null; + + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const when = Date.parse(raw); + if (Number.isFinite(when)) { + return Math.max(0, when - Date.now()); + } + + return null; +} + +async function readErrorMessage(response) { + const contentType = response.headers.get('content-type') || ''; + try { + if (contentType.includes('application/json')) { + const payload = await response.json(); + return payload?.message || payload?.error || JSON.stringify(payload); + } + + const text = await response.text(); + return text || `HTTP ${response.status}`; + } catch { + return `HTTP ${response.status}`; + } +} + +async function fetchWithRetry(url, options = {}, retryOptions = {}) { + const { + retries = 3, + baseDelayMs = 600, + maxDelayMs = 5000, + label = 'request', + retryStatuses = [408, 429, 500, 502, 503, 504], + } = retryOptions; + + let lastError = null; + + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(url, options); + if (!retryStatuses.includes(response.status) || attempt === retries) { + return response; + } + + const retryDelay = parseRetryAfter(response.headers) + ?? Math.min(maxDelayMs, baseDelayMs * (2 ** attempt)); + console.warn(`[retry] ${label} returned ${response.status}; retrying in ${retryDelay}ms`); + await delay(retryDelay); + } catch (error) { + lastError = error; + if (attempt === retries) throw error; + + const retryDelay = Math.min(maxDelayMs, baseDelayMs * (2 ** attempt)); + console.warn(`[retry] ${label} failed (${error.message}); retrying in ${retryDelay}ms`); + await delay(retryDelay); + } + } + + throw lastError || new Error(`${label} failed.`); +} + +function stripPhotoDataUrl(value) { + if (Array.isArray(value)) { + return value.map((item) => stripPhotoDataUrl(item)); + } + + if (!value || typeof value !== 'object') { + return value; + } + + const clone = {}; + for (const [key, child] of Object.entries(value)) { + if (key === 'photoDataUrl') continue; + clone[key] = stripPhotoDataUrl(child); + } + return clone; +} + +function isStoredTokenFresh(boxConfig) { + const accessToken = normalizeString(boxConfig.accessToken); + if (!accessToken) return false; + + const expiresAt = normalizeString(boxConfig.accessTokenExpiresAt); + if (!expiresAt) return true; + + const expiryTime = Date.parse(expiresAt); + if (!Number.isFinite(expiryTime)) return true; + return expiryTime - TOKEN_REFRESH_SKEW_MS > Date.now(); +} + +function hasBoxAuth(boxConfig = {}) { + const authMode = normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase(); + if (authMode === 'access_token') { + return !!normalizeString(boxConfig.accessToken); + } + if (authMode === 'client_credentials') { + return !!( + normalizeString(boxConfig.clientId) && + normalizeString(boxConfig.clientSecret) && + normalizeString(boxConfig.subjectType) && + normalizeString(boxConfig.subjectId) + ); + } + return !!( + normalizeString(boxConfig.accessToken) || + ( + normalizeString(boxConfig.refreshToken) && + normalizeString(boxConfig.clientId) && + normalizeString(boxConfig.clientSecret) + ) + ); +} + +function computeBoxStatus(boxConfig = {}) { + const intakeFolderId = normalizeString(boxConfig.intakeFolderId); + const caseRootFolderId = normalizeString(boxConfig.caseRootFolderId || intakeFolderId); + const metadataScope = normalizeString(boxConfig.metadataScope); + const metadataTemplateKey = normalizeString(boxConfig.metadataTemplateKey); + return { + authMode: normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase(), + configured: hasBoxAuth(boxConfig) && !!intakeFolderId, + hasAuth: hasBoxAuth(boxConfig), + intakeFolderId, + caseRootFolderId, + metadataScope, + metadataTemplateKey, + hasCustomMetadataTemplate: !!(metadataScope && metadataTemplateKey), + hasIntakeFolderId: !!intakeFolderId, + hasCaseRootFolderId: !!caseRootFolderId, + usingTemporaryAccessToken: normalizeString(boxConfig.authMode).toLowerCase() === 'access_token', + }; +} + +function buildBoxConfigError(boxConfig = {}) { + const authMode = normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase(); + if (authMode === 'client_credentials') { + return 'Box client credentials are incomplete. Save client ID, client secret, subject type, subject ID, and the intake folder in settings.html.'; + } + if (authMode === 'access_token') { + return 'Box access token is missing. Open settings.html and save the Box integration first.'; + } + return 'Box OAuth refresh credentials are incomplete. Save the client ID, client secret, refresh token, and intake folder in settings.html.'; +} + +function createBoxService({ configStore }) { + let tokenRefreshPromise = null; + + async function readBoxConfig() { + const config = await configStore.readConfig(); + const fileBox = config.box || {}; + // Env-var overrides so the Box CCG connection survives on hosts that wipe disk + // (HuggingFace Spaces, Render, Railway, etc.). Set these as the host's secrets. + const E = process.env; + const env = {}; + if (E.BOX_AUTH_MODE) env.authMode = E.BOX_AUTH_MODE; + if (E.BOX_CLIENT_ID) env.clientId = E.BOX_CLIENT_ID; + if (E.BOX_CLIENT_SECRET) env.clientSecret = E.BOX_CLIENT_SECRET; + if (E.BOX_SUBJECT_TYPE) env.subjectType = E.BOX_SUBJECT_TYPE; + if (E.BOX_SUBJECT_ID) env.subjectId = E.BOX_SUBJECT_ID; + if (E.BOX_INTAKE_FOLDER_ID) env.intakeFolderId = E.BOX_INTAKE_FOLDER_ID; + if (E.BOX_CASE_ROOT_FOLDER_ID) env.caseRootFolderId = E.BOX_CASE_ROOT_FOLDER_ID; + if (E.BOX_METADATA_SCOPE) env.metadataScope = E.BOX_METADATA_SCOPE; + if (E.BOX_METADATA_TEMPLATE_KEY) env.metadataTemplateKey = E.BOX_METADATA_TEMPLATE_KEY; + return { ...fileBox, ...env }; + } + + async function getClientConfig() { + const boxConfig = await readBoxConfig(); + return configStore.sanitizeBoxConfigForClient(boxConfig); + } + + async function getStatus() { + return computeBoxStatus(await readBoxConfig()); + } + + async function saveConfig(patch = {}, options = {}) { + const updated = await configStore.queueMutation((config) => ({ + ...config, + box: configStore.mergeBoxConfig(config.box, patch, options), + })); + + return { + config: configStore.sanitizeBoxConfigForClient(updated.box), + status: computeBoxStatus(updated.box), + }; + } + + async function resetConfig() { + return saveConfig({}, { reset: true }); + } + + async function persistTokens(tokenPatch = {}) { + const updated = await configStore.queueMutation((config) => ({ + ...config, + box: configStore.mergeBoxConfig(config.box, tokenPatch), + })); + return updated.box; + } + + async function requestBoxTokenViaRefresh(boxConfig, { persist = true } = {}) { + const refreshToken = normalizeString(boxConfig.refreshToken); + const clientId = normalizeString(boxConfig.clientId); + const clientSecret = normalizeString(boxConfig.clientSecret); + if (!refreshToken || !clientId || !clientSecret) { + throw new Error(buildBoxConfigError(boxConfig)); + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetchWithRetry(BOX_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }, { label: 'Box OAuth refresh token request' }); + + if (!response.ok) { + throw new Error(`Box token refresh failed (${response.status}): ${await readErrorMessage(response)}`); + } + + const payload = await response.json(); + const expiresAt = payload.expires_in + ? new Date(Date.now() + (Number(payload.expires_in) * 1000)).toISOString() + : ''; + + if (persist) { + await persistTokens({ + accessToken: payload.access_token || '', + accessTokenExpiresAt: expiresAt, + refreshToken: payload.refresh_token || refreshToken, + }); + } + + return normalizeString(payload.access_token); + } + + async function requestBoxTokenViaClientCredentials(boxConfig, { persist = true } = {}) { + const clientId = normalizeString(boxConfig.clientId); + const clientSecret = normalizeString(boxConfig.clientSecret); + const subjectType = normalizeString(boxConfig.subjectType); + const subjectId = normalizeString(boxConfig.subjectId); + if (!clientId || !clientSecret || !subjectType || !subjectId) { + throw new Error(buildBoxConfigError(boxConfig)); + } + + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + box_subject_type: subjectType, + box_subject_id: subjectId, + }); + + const response = await fetchWithRetry(BOX_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }, { label: 'Box client credentials token request' }); + + if (!response.ok) { + throw new Error(`Box client credentials token request failed (${response.status}): ${await readErrorMessage(response)}`); + } + + const payload = await response.json(); + const expiresAt = payload.expires_in + ? new Date(Date.now() + (Number(payload.expires_in) * 1000)).toISOString() + : ''; + + if (persist) { + await persistTokens({ + accessToken: payload.access_token || '', + accessTokenExpiresAt: expiresAt, + }); + } + + return normalizeString(payload.access_token); + } + + async function resolveAccessToken(boxConfig = null, options = {}) { + const persist = options.persist !== false; + const effectiveConfig = boxConfig || await readBoxConfig(); + const authMode = normalizeString(effectiveConfig.authMode || 'oauth_refresh').toLowerCase(); + const canRefreshOAuth = !!( + normalizeString(effectiveConfig.refreshToken) && + normalizeString(effectiveConfig.clientId) && + normalizeString(effectiveConfig.clientSecret) + ); + + if (authMode === 'access_token') { + const token = normalizeString(effectiveConfig.accessToken); + if (!token) throw new Error(buildBoxConfigError(effectiveConfig)); + return token; + } + + if ((authMode === 'oauth_refresh' && canRefreshOAuth && normalizeString(effectiveConfig.accessTokenExpiresAt) && isStoredTokenFresh(effectiveConfig)) + || (authMode === 'oauth_refresh' && !canRefreshOAuth && isStoredTokenFresh(effectiveConfig)) + || (authMode === 'client_credentials' && normalizeString(effectiveConfig.accessTokenExpiresAt) && isStoredTokenFresh(effectiveConfig))) { + return normalizeString(effectiveConfig.accessToken); + } + + if (tokenRefreshPromise) { + return tokenRefreshPromise; + } + + tokenRefreshPromise = (async () => { + if (authMode === 'client_credentials') { + return requestBoxTokenViaClientCredentials(effectiveConfig, { persist }); + } + return requestBoxTokenViaRefresh(effectiveConfig, { persist }); + })(); + + try { + return await tokenRefreshPromise; + } finally { + tokenRefreshPromise = null; + } + } + + async function boxRequestJson(boxPath, options = {}, overrideConfig = null) { + const boxConfig = overrideConfig || await readBoxConfig(); + const accessToken = await resolveAccessToken(boxConfig, { persist: !overrideConfig }); + const response = await fetchWithRetry(`${BOX_API_BASE}${boxPath}`, { + method: options.method || 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }, { label: options.label || `Box ${options.method || 'GET'} ${boxPath}` }); + + if (!response.ok) { + throw new Error(`${options.label || 'Box request'} failed (${response.status}): ${await readErrorMessage(response)}`); + } + + if (options.rawResponse) return response; + return response.json(); + } + + async function uploadBufferToBox({ folderId, fileName, buffer, contentType, boxConfig = null }) { + const effectiveConfig = boxConfig || await readBoxConfig(); + const accessToken = await resolveAccessToken(effectiveConfig, { persist: !boxConfig }); + + const formData = new FormData(); + formData.append('attributes', JSON.stringify({ + name: fileName, + parent: { id: folderId }, + })); + formData.append('file', new Blob([buffer], { type: contentType || 'application/octet-stream' }), fileName); + + const response = await fetchWithRetry(BOX_UPLOAD_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + }, { label: `Box upload ${fileName}` }); + + if (!response.ok) { + if (response.status === 409) { + try { + const conflictData = await response.json(); + const existingFileId = conflictData.context_info?.conflicts?.id + || conflictData.context_info?.conflicts?.[0]?.id; + + if (existingFileId) { + console.log(`[BoxService] File "${fileName}" already exists (id: ${existingFileId}). Uploading new version.`); + const versionFormData = new FormData(); + versionFormData.append('file', new Blob([buffer], { type: contentType || 'application/octet-stream' }), fileName); + + const versionUrl = `https://upload.box.com/api/2.0/files/${existingFileId}/content`; + const versionResponse = await fetchWithRetry(versionUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: versionFormData, + }, { label: `Box upload new version ${fileName}` }); + + if (versionResponse.ok) { + const versionPayload = await versionResponse.json(); + const entry = versionPayload.entries?.[0] || {}; + return { + fileId: normalizeString(entry.id), + fileName: normalizeString(entry.name), + sharedLink: entry.shared_link?.url || null, + }; + } else { + throw new Error(`Box upload new version failed (${versionResponse.status}): ${await readErrorMessage(versionResponse)}`); + } + } + } catch (conflictError) { + throw new Error(`Box upload conflict resolution failed: ${conflictError.message}`); + } + } + throw new Error(`Box upload failed (${response.status}): ${await readErrorMessage(response)}`); + } + + const payload = await response.json(); + const entry = payload.entries?.[0] || {}; + return { + fileId: normalizeString(entry.id), + fileName: normalizeString(entry.name), + sharedLink: entry.shared_link?.url || null, + }; + } + + async function testConnection() { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.hasAuth) { + return { ok: false, error: buildBoxConfigError(boxConfig) }; + } + if (!status.hasIntakeFolderId) { + return { ok: false, error: 'Incoming intake folder ID is required.' }; + } + + try { + const intakeFolder = await boxRequestJson(`/folders/${encodeURIComponent(status.intakeFolderId)}`, { + label: 'Box intake folder lookup', + }, boxConfig); + + let caseRootFolder = null; + if (status.caseRootFolderId && status.caseRootFolderId !== status.intakeFolderId) { + caseRootFolder = await boxRequestJson(`/folders/${encodeURIComponent(status.caseRootFolderId)}`, { + label: 'Box case root folder lookup', + }, boxConfig); + } + + return { + ok: true, + folderName: intakeFolder.name || '(unknown)', + caseRootFolderName: caseRootFolder?.name || intakeFolder.name || '(unknown)', + }; + } catch (error) { + return { ok: false, error: error.message }; + } + } + + // Create an open, downloadable shared link and return its direct download_url + // (what Box Doc Gen needs to fetch+embed the photo as an image tag). + async function createDirectSharedLink(fileId, boxConfig) { + try { + const token = await resolveAccessToken(boxConfig); + const response = await fetchWithRetry(`${BOX_API_BASE}/files/${encodeURIComponent(fileId)}?fields=shared_link`, { + method: 'PUT', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ shared_link: { access: 'open', permissions: { can_download: true } } }), + }, { label: `Box shared link ${fileId}` }); + if (!response.ok) return ''; + const data = await response.json(); + return normalizeString(data.shared_link?.download_url || data.shared_link?.url || ''); + } catch (error) { + console.warn('[box-service] createDirectSharedLink failed:', error.message); + return ''; + } + } + + async function uploadPhoto({ caseId, fileBuffer, contentType = 'image/jpeg' }) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) { + throw new Error(buildBoxConfigError(boxConfig)); + } + + // Photos go to a SEPARATE "Pothole Photos" folder โ€” not the trigger folder โ€” + // so the photo upload never fires WF1 (which would crash the Extract Agent on an image). + let photoFolderId = status.intakeFolderId; + try { + const token = await resolveAccessToken(boxConfig); + photoFolderId = await getOrCreateFolder(status.intakeFolderId, 'Pothole Photos', token); + } catch (e) { + console.warn('[box-service] could not create Pothole Photos folder, using intake:', e.message); + } + const upload = await uploadBufferToBox({ + folderId: photoFolderId, + fileName: `${caseId}_photo.jpg`, + buffer: fileBuffer, + contentType, + boxConfig, + }); + // photoUrl = direct shared link to the photo (used in the JSON, the mail, and docs). + let photoUrl = await createDirectSharedLink(upload.fileId, boxConfig); + if (!photoUrl) { + // Box can briefly reject a shared link right after upload โ€” retry once. + await new Promise((r) => setTimeout(r, 700)); + photoUrl = await createDirectSharedLink(upload.fileId, boxConfig); + } + return { ...upload, photoUrl }; + } + + function buildSubmissionSidecar(metadata = {}, intakeFolderId = '') { + const sanitized = stripPhotoDataUrl(metadata); + const caseId = normalizeString(sanitized.caseId); + const submittedAt = normalizeString(sanitized.submittedAt) || nowIso(); + const workflow = buildInitialWorkflow( + { ...sanitized, caseId, submittedAt }, + { folderId: intakeFolderId, folderName: 'Incoming Detections' } + ); + + return { + caseId, + submittedAt, + ...sanitized, + workflow, + ...flattenWorkflowForBox(workflow), + }; + } + + async function uploadSidecar({ caseId, metadata }) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) { + throw new Error(buildBoxConfigError(boxConfig)); + } + + const sidecarPayload = buildSubmissionSidecar({ ...metadata, caseId }, status.intakeFolderId); + const sidecarBuffer = Buffer.from(JSON.stringify(sidecarPayload, null, 2), 'utf8'); + const result = await uploadBufferToBox({ + folderId: status.intakeFolderId, + fileName: `${caseId}_metadata.json`, + buffer: sidecarBuffer, + contentType: 'application/json', + boxConfig, + }); + + return { + ...result, + sidecarPayload, + }; + } + + async function generateAndUploadAuditReport({ caseId, caseRecord }) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) { + throw new Error(buildBoxConfigError(boxConfig)); + } + + const report = caseRecord.report || {}; + const folderId = report.boxFolderId || status.caseRootFolderId || status.intakeFolderId; + + const mdContent = `# Pothole Case Audit Report - ${caseId} +**Status:** Resolved +**Generated on:** ${nowIso()} + +--- + +## 1. Intake Information +* **Case ID:** ${caseId} +* **Submission Timestamp:** ${report.submittedAt || caseRecord.createdAt || 'N/A'} +* **Location:** ${report.address || 'Unknown Address'} +* **GPS Coordinates:** ${report.location?.lat ?? 'N/A'}, ${report.location?.lng ?? 'N/A'} +* **Reporter Name:** ${report.anonymous ? 'Anonymous' : (report.reporterName || 'N/A')} +* **Reporter Contact:** ${report.anonymous ? 'Anonymous' : (report.reporterContact || 'N/A')} +* **Initial Description:** ${report.description || 'No description provided.'} +* **AI Initial Classification:** ${report.aiResult || 'N/A'} + +--- + +## 2. Enrichment & Severity Triage +* **Enriched At:** ${report.enrichment?.enrichedAt || 'N/A'} +* **Severity Score:** ${report.enrichment?.severityScore ?? 'N/A'} / 100 +* **Severity Level:** ${report.enrichment?.severityLevel || 'N/A'} +* **Ward / District:** ${report.ward || 'N/A'} / ${report.district || 'N/A'} +* **Maintenance Zone:** ${report.maintenanceZone || 'N/A'} +* **Duplicate Status:** ${report.enrichment?.duplicate?.isDuplicate ? `Duplicate (${report.enrichment.duplicate.duplicateCount} prior reports)` : 'New Incident'} +* **Weather Risk:** ${report.enrichment?.weather?.condition || 'N/A'} (Risk Level: ${report.enrichment?.weather?.riskLevel || 'N/A'}) + +--- + +## 3. Dispatch & Field Operations +* **Assigned Crew / Lead:** ${report.crewName || report.assignedTo || 'Unassigned'} +* **Work Status:** ${caseRecord.status || 'Resolved'} + +--- + +## 4. Completion & Resolution Proof +* **Completion Timestamp:** ${report.resolvedAt || 'N/A'} +* **Resolution Notes:** ${report.resolutionNotes || 'No notes provided.'} +* **Materials Used / Quantity:** ${report.materialsUsed || 'N/A'} +* **Labor Hours Expended:** ${report.laborHours || 'N/A'} +* **Crew Digital Signature:** ${report.crewSignature || 'N/A'} + +--- + +## 5. Visual Evidence (Before vs. After) +* **Before Photo (Intake):** ${report.photoFileId ? `[View Intake Photo](https://app.box.com/file/${report.photoFileId})` : 'N/A'} +* **After Photo (Resolution):** ${report.afterPhotoFileId ? `[View Repair Photo](https://app.box.com/file/${report.afterPhotoFileId})` : 'N/A'} + +--- + +## 6. System Logs & Audit Trail +* **Created At:** ${caseRecord.createdAt || 'N/A'} +* **Last Updated At:** ${caseRecord.updatedAt || 'N/A'} +`; + + const reportBuffer = Buffer.from(mdContent, 'utf8'); + const fileName = `${caseId}_audit_report.md`; + + return uploadBufferToBox({ + folderId, + fileName, + buffer: reportBuffer, + contentType: 'text/markdown', + boxConfig, + }); + } + + // Locate the Box case folder for a case: prefer the stored boxFolderId, else + // search for the "Case-{caseId}" folder by name. + async function findCaseFolderId(caseId, caseRecord, boxConfig) { + const stored = caseRecord?.report?.boxFolderId || caseRecord?.boxFolderId; + if (stored) return String(stored); + try { + const params = new URLSearchParams({ query: `Case-${caseId}`, type: 'folder', limit: '10' }); + const payload = await boxRequestJson(`/search?${params.toString()}`, { + label: `Box folder search ${caseId}`, + }, boxConfig); + const match = (payload.entries || []).find( + (e) => e.type === 'folder' && e.name === `Case-${caseId}`, + ); + return match ? String(match.id) : null; + } catch (error) { + console.warn('[box-service] findCaseFolderId failed:', error.message); + return null; + } + } + + // Flip the case folder's potholeCase metadata to status=resolved + resolvedAt, + // so the Box App "Resolved" view (which reads metadata) reflects the closeout. + async function markCaseResolvedInBox({ caseId, caseRecord, resolvedAt, resolvedBy } = {}) { + const id = String(caseId || caseRecord?.caseId || '').trim(); + if (!id) return { skipped: true, reason: 'missing_case_id' }; + + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) return { skipped: true, reason: 'box_not_configured' }; + if (!status.hasCustomMetadataTemplate) return { skipped: true, reason: 'no_metadata_template' }; + + const folderId = await findCaseFolderId(id, caseRecord, boxConfig); + if (!folderId) return { skipped: true, reason: 'case_folder_not_found' }; + + const token = await resolveAccessToken(boxConfig); + const template = { scope: boxConfig.metadataScope, templateKey: boxConfig.metadataTemplateKey }; + const kv = { + status: 'resolved', + resolvedAt: resolvedAt || nowIso(), + supervisorWardEngineer: String(resolvedBy || '').trim(), + }; + await applyFolderMetadata(folderId, kv, token, { template }); + return { ok: true, folderId, applied: kv }; + } + + // Set any status on a case folder's metadata (Approve/Resolve/Reject/Reopen from the dashboard). + async function setCaseStatusInBox({ caseId, caseRecord, status, by } = {}) { + const id = String(caseId || caseRecord?.caseId || '').trim(); + if (!id) return { skipped: true, reason: 'missing_case_id' }; + const boxConfig = await readBoxConfig(); + const st = computeBoxStatus(boxConfig); + if (!st.configured) return { skipped: true, reason: 'box_not_configured' }; + if (!st.hasCustomMetadataTemplate) return { skipped: true, reason: 'no_metadata_template' }; + const folderId = await findCaseFolderId(id, caseRecord, boxConfig); + if (!folderId) return { skipped: true, reason: 'case_folder_not_found' }; + const token = await resolveAccessToken(boxConfig); + const template = { scope: boxConfig.metadataScope, templateKey: boxConfig.metadataTemplateKey }; + const kv = { status }; + if (by) kv.supervisorWardEngineer = String(by).trim(); + if (String(status).toLowerCase() === 'resolved') kv.resolvedAt = nowIso(); + await applyFolderMetadata(folderId, kv, token, { template }); + return { ok: true, folderId, applied: kv }; + } + + async function uploadCaseArtifact({ caseId, caseRecord, fileName, content, contentType = 'text/plain; charset=utf-8' }) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) { + return { + skipped: true, + reason: 'box_not_configured', + }; + } + + const report = caseRecord?.report || {}; + const folderId = report.boxFolderId || status.caseRootFolderId || status.intakeFolderId; + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8'); + + const upload = await uploadBufferToBox({ + folderId, + fileName, + buffer, + contentType, + boxConfig, + }); + + return { + caseId: normalizeString(caseId || caseRecord?.caseId), + folderId, + ...upload, + }; + } + + // Ensure a single dedicated "Closeout Reports" folder under the case root. All + // before/after reports go here so a collaborator can watch ONE folder and get + // exactly one notification per closeout (no per-case-folder photo/audit noise). + const _closeoutReportsFolderCache = {}; + const CLOSEOUT_REPORTS_FOLDER_NAME = 'Closeout Reports'; + async function ensureCloseoutReportsFolder(boxConfig) { + const status = computeBoxStatus(boxConfig); + const rootId = normalizeString(status.caseRootFolderId || status.intakeFolderId); + if (!rootId) return ''; + if (_closeoutReportsFolderCache[rootId]) return _closeoutReportsFolderCache[rootId]; + const token = await resolveAccessToken(boxConfig); + const folderId = await getOrCreateFolder(rootId, CLOSEOUT_REPORTS_FOLDER_NAME, token); + _closeoutReportsFolderCache[rootId] = folderId; + return folderId; + } + + // Upload a before/after report into the dedicated "Closeout Reports" folder. + async function uploadCloseoutReport({ caseId, fileName, content, contentType = 'application/octet-stream' }) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) return { skipped: true, reason: 'box_not_configured' }; + + const folderId = await ensureCloseoutReportsFolder(boxConfig); + if (!folderId) return { skipped: true, reason: 'no_root_folder' }; + + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8'); + const upload = await uploadBufferToBox({ folderId, fileName, buffer, contentType, boxConfig }); + return { + caseId: normalizeString(caseId), + folderId, + fileName, + ...upload, + }; + } + + // Upload a backend-generated document (.docx) to the case folder (or a dedicated + // "Pothole Documents" folder) โ€” never the trigger root โ€” and return a shared link. + async function uploadGeneratedDoc({ caseId, caseRecord, fileName, buffer }) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) throw new Error(buildBoxConfigError(boxConfig)); + + let folderId = await findCaseFolderId(caseId, caseRecord, boxConfig); + if (!folderId) { + const token = await resolveAccessToken(boxConfig); + folderId = await getOrCreateFolder(status.intakeFolderId, 'Pothole Documents', token); + } + + const upload = await uploadBufferToBox({ + folderId, + fileName, + buffer, + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + boxConfig, + }); + + let sharedLink = ''; + try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {} + if (!sharedLink) { + // Box can briefly reject a shared link right after upload โ€” retry once. + await new Promise((r) => setTimeout(r, 700)); + try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {} + } + + return { caseId: normalizeString(caseId || caseRecord?.caseId), folderId, fileName, sharedLink, ...upload }; + } + + // Create a Box Sign request โ€” emails the signer(s) a fillable doc to complete + sign (WF2 crew closeout). + async function createSignRequest({ sourceFileId, parentFolderId, signers, emailSubject, emailMessage } = {}) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) throw new Error(buildBoxConfigError(boxConfig)); + if (!sourceFileId) throw new Error('sourceFileId is required for the sign request.'); + if (!Array.isArray(signers) || !signers.length) throw new Error('At least one signer email is required.'); + const token = await resolveAccessToken(boxConfig); + const body = { + source_files: [{ id: String(sourceFileId), type: 'file' }], + signers: signers.map((s, i) => ({ email: s.email, role: 'signer', order: i + 1 })), + }; + if (parentFolderId) body.parent_folder = { id: String(parentFolderId), type: 'folder' }; + if (emailSubject) body.email_subject = emailSubject; + if (emailMessage) body.email_message = emailMessage; + const response = await fetchWithRetry(`${BOX_API_BASE}/sign_requests`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, { label: 'Box Sign request' }); + if (!response.ok) { + throw new Error(`Box Sign request failed (${response.status}): ${await readErrorMessage(response)}`); + } + const data = await response.json(); + return { + ok: true, + signRequestId: data.id, + status: data.status, + signers: (data.signers || []).map((s) => s.email), + prepareUrl: data.prepare_url || '', + }; + } + + async function organizeCase(payload = {}) { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) { + throw new Error(buildBoxConfigError(boxConfig)); + } + + const accessToken = await resolveAccessToken(boxConfig); + return organizeInBox({ + caseId: normalizeString(payload.caseId), + ward: normalizeString(payload.ward), + district: normalizeString(payload.district), + photoFileId: normalizeString(payload.photoFileId), + sidecarFileId: normalizeString(payload.sidecarFileId), + enrichment: stripPhotoDataUrl(payload.enrichment || {}), + reportPayload: stripPhotoDataUrl(payload.reportPayload || {}), + rootFolderId: status.caseRootFolderId || status.intakeFolderId, + metadataTemplate: { + scope: normalizeString(boxConfig.metadataScope), + templateKey: normalizeString(boxConfig.metadataTemplateKey), + }, + token: accessToken, + }); + } + + async function getFileContent(fileId) { + const boxConfig = await readBoxConfig(); + const accessToken = await resolveAccessToken(boxConfig); + const response = await fetchWithRetry(`${BOX_API_BASE}/files/${encodeURIComponent(fileId)}/content`, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, { label: `Box file content ${fileId}` }); + + if (!response.ok) { + throw new Error(`Box file preview failed (${response.status}): ${await readErrorMessage(response)}`); + } + + return { + contentType: response.headers.get('content-type') || 'application/octet-stream', + contentLength: response.headers.get('content-length') || '', + body: Buffer.from(await response.arrayBuffer()), + }; + } + + // Look up a single case straight from Box and return its REAL status (read from + // the case folder's potholeCase metadata) so the citizen tracker reflects live + // supervisor decisions instead of being pinned to "Under Review". + async function searchCase(caseId) { + const id = String(caseId || '').trim(); + if (!id) return null; + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) return null; + + try { + const folderId = await findCaseFolderId(id, null, boxConfig); + if (!folderId) return null; + + const scope = normalizeString(boxConfig.metadataScope) || 'enterprise'; + const templateKey = normalizeString(boxConfig.metadataTemplateKey) || 'potholeCase'; + let md = {}; + try { + md = await boxRequestJson( + `/folders/${folderId}/metadata/${scope}/${templateKey}`, + { label: `Box case metadata ${id}` }, + boxConfig, + ); + } catch (_) { + // Folder exists but has no potholeCase metadata yet โ€” treat as freshly submitted. + md = {}; + } + + const num = (v) => (v === undefined || v === null || v === '' ? null : Number(v)); + const lat = num(md.latitude); + const lng = num(md.longitude); + return { + caseId: md.caseId || id, + status: normalizeString(md.status) || 'Under Review', + ts: md.submittedAt || Date.now(), + address: md.address || '', + ward: md.ward || '', + location: (lat != null && lng != null) ? { lat, lng } : null, + reporterContact: md.reporterContact || '', + potholeSize: md.potholeSize || '', + severityLevel: md.severityLevel || '', + severityScore: num(md.severityScore0100), + weatherRisk: md.weatherRisk || '', + district: md.district || '', + maintenanceZone: md.maintenanceZone || '', + resolvedAt: md.resolvedAt || '', + folderId, + source: 'box', + }; + } catch (error) { + console.warn('[box-service] searchCase failed:', error.message); + } + + return null; + } + + // Pull the citizen's email out of the case folder's JSON sidecar(s). The submission + // sidecar ({caseId}_metadata.json) and enriched metadata carry reporterContact even + // when the Box metadata template field is empty โ€” so read the JSON and extract it. + async function extractCitizenEmailFromCaseJson(caseId, folderId, boxConfig) { + try { + const items = await boxRequestJson( + `/folders/${folderId}/items?fields=id,name,type&limit=1000`, + { label: `case items ${caseId}` }, + boxConfig, + ); + const score = (name) => { + if (/_metadata\.json$/i.test(name) && !/enriched/i.test(name)) return 3; // submission sidecar + if (/enriched.*\.json$/i.test(name)) return 2; + return 1; + }; + const jsonFiles = (items.entries || []) + .filter((e) => e.type === 'file' && /\.json$/i.test(e.name)) + .sort((a, b) => score(b.name) - score(a.name)); + + for (const file of jsonFiles) { + let text; + try { + const content = await getFileContent(file.id); + text = content.body.toString('utf8'); + } catch (_) { continue; } + + let email = ''; + try { + const obj = JSON.parse(text); + email = extractEmail( + obj.reporterContact || obj.reporterEmail || obj.contact || obj.email + || obj.report?.reporterContact || obj.report?.reporterEmail || obj.report?.contact || '', + ); + } catch (_) { /* not valid JSON โ€” fall through to a raw scan */ } + if (!email) email = extractEmail(text); + if (email) return email; + } + } catch (error) { + console.warn('[box-service] extractCitizenEmailFromCaseJson failed:', error.message); + } + return ''; + } + + // Read the submission sidecar JSON for a case and return the reporter + location data + // captured at submission (reporterName, reporterContact, anonymous, address, location). + // The metadata template often doesn't carry the reporter name, but the sidecar does. + async function getCaseSubmissionData(caseId) { + const id = String(caseId || '').trim(); + if (!id) return null; + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) return null; + const folderId = await findCaseFolderId(id, null, boxConfig); + if (!folderId) return null; + try { + const items = await boxRequestJson( + `/folders/${folderId}/items?fields=id,name,type&limit=1000`, + { label: `case items ${id}` }, + boxConfig, + ); + const score = (name) => (/_metadata\.json$/i.test(name) && !/enriched/i.test(name)) ? 3 : (/enriched.*\.json$/i.test(name) ? 2 : 1); + const jsonFiles = (items.entries || []) + .filter((e) => e.type === 'file' && /\.json$/i.test(e.name)) + .sort((a, b) => score(b.name) - score(a.name)); + for (const file of jsonFiles) { + let obj; + try { obj = JSON.parse((await getFileContent(file.id)).body.toString('utf8')); } catch (_) { continue; } + const r = (obj && obj.report) ? obj.report : (obj || {}); + return { + reporterName: normalizeString(r.reporterName || obj.reporterName), + reporterContact: normalizeString(r.reporterContact || obj.reporterContact || r.reporterEmail || obj.reporterEmail), + anonymous: (r.anonymous !== undefined ? r.anonymous : obj.anonymous), + address: normalizeString(r.address || obj.address), + location: r.location || obj.location || null, + ward: normalizeString(r.ward || obj.ward), + }; + } + } catch (error) { + console.warn('[box-service] getCaseSubmissionData failed:', error.message); + } + return null; + } + + // Notify a citizen of a status change *via Box*: drop a short status-update note + // into the case folder, then collaborate the citizen (viewer) on that note with + // notify=true so Box emails them a link โ€” the same Box-native "share the summary" + // path used elsewhere, so no separate email service is needed. + async function notifyCitizenViaBox({ caseId, caseRecord, status, citizenEmail, summary } = {}) { + const id = String(caseId || caseRecord?.caseId || '').trim(); + if (!id) return { skipped: true, reason: 'missing_case_id' }; + + const boxConfig = await readBoxConfig(); + const st = computeBoxStatus(boxConfig); + if (!st.configured) return { skipped: true, reason: 'box_not_configured' }; + + const folderId = await findCaseFolderId(id, caseRecord, boxConfig); + if (!folderId) return { skipped: true, reason: 'case_folder_not_found' }; + + // Extract the citizen email: first from whatever contact string we were handed, + // then from the case-folder JSON sidecar (reporterContact lives in the JSON even + // when the Box metadata field is blank). + let email = extractEmail(citizenEmail || caseRecord?.report?.reporterContact || ''); + if (!email) email = await extractCitizenEmailFromCaseJson(id, folderId, boxConfig); + if (!email) return { skipped: true, reason: 'no_citizen_email' }; + + const statusLabel = String(status || caseRecord?.status || 'Updated').trim(); + const stamp = nowIso().slice(0, 10); + const fileName = `Status Update - Case ${id} - ${statusLabel} (${stamp}).txt`; + const body = summary || buildCitizenStatusNote(id, statusLabel, caseRecord); + + let upload; + try { + upload = await uploadBufferToBox({ + folderId, + fileName, + buffer: Buffer.from(body, 'utf8'), + contentType: 'text/plain; charset=utf-8', + boxConfig, + }); + } catch (error) { + return { ok: false, emailed: false, reason: 'note_upload_failed', error: error.message }; + } + + let sharedLink = ''; + try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {} + + // Add the citizen as a viewer on the note file -> Box emails them the invite/link. + try { + const collab = await boxRequestJson('/collaborations?notify=true', { + method: 'POST', + label: `Box notify citizen ${id}`, + body: { + item: { type: 'file', id: String(upload.fileId) }, + accessible_by: { type: 'user', login: email }, + role: 'viewer', + }, + }, boxConfig); + return { + ok: true, + emailed: true, + to: email, + status: statusLabel, + fileId: upload.fileId, + sharedLink, + collaborationId: collab?.id || '', + }; + } catch (error) { + // The note is in Box even if the collaboration email failed (e.g. external + // collaboration disabled) โ€” surface that without failing the status update. + return { ok: false, emailed: false, to: email, fileId: upload.fileId, sharedLink, error: error.message }; + } + } + + // Give a supervisor/ops recipient access to closeout reports *via Box*, as a ONE-TIME + // invite. Rather than collaborating them on each case's report file (which would email + // a fresh "invite to collaborate" for every case), we collaborate them once on the + // stable case-root folder. Box emails that single invite the first time; on every + // later case they are already a collaborator, so no repeat invite is sent โ€” they just + // have standing access, and we hand back a direct shared link to the new report. + async function notifyCloseoutViaBox({ caseId, caseRecord, reportFileId, recipientEmail } = {}) { + const id = String(caseId || caseRecord?.caseId || '').trim(); + if (!id) return { skipped: true, reason: 'missing_case_id' }; + + const email = extractEmail(recipientEmail || ''); + if (!email) return { skipped: true, reason: 'no_recipient_email' }; + + const boxConfig = await readBoxConfig(); + const st = computeBoxStatus(boxConfig); + if (!st.configured) return { skipped: true, reason: 'box_not_configured' }; + + // Direct shared link to THIS case's report (handy for the response/UI regardless). + const fileId = String(reportFileId || '').trim(); + let sharedLink = ''; + if (fileId) { try { sharedLink = await createDirectSharedLink(fileId, boxConfig); } catch (_) {} } + + // Grant standing access to the dedicated "Closeout Reports" folder so the recipient + // can watch exactly that one folder for new before/after reports. + const rootFolderId = await ensureCloseoutReportsFolder(boxConfig); + if (!rootFolderId) { + // No stable workspace folder to grant standing access โ€” the report link still works. + return { ok: !!sharedLink, emailed: false, to: email, fileId, sharedLink, reason: 'no_root_folder' }; + } + + // One-time collaboration on the Closeout Reports folder -> Box invites them once. + try { + const collab = await boxRequestJson('/collaborations?notify=true', { + method: 'POST', + label: `Box closeout access ${email}`, + body: { + item: { type: 'folder', id: rootFolderId }, + accessible_by: { type: 'user', login: email }, + role: 'viewer', + }, + }, boxConfig); + return { ok: true, emailed: true, firstTime: true, to: email, fileId, sharedLink, folderId: rootFolderId, collaborationId: collab?.id || '' }; + } catch (error) { + // Already a collaborator -> they were invited once before. No new invite is sent; + // they keep standing access to every report. This is the intended steady state. + if (/already a collaborator/i.test(error.message || '')) { + return { ok: true, emailed: false, alreadyHasAccess: true, to: email, fileId, sharedLink, folderId: rootFolderId }; + } + // The report is saved in Box even if the collaboration failed (e.g. external + // collaboration disabled) โ€” surface that without failing the closeout. + return { ok: false, emailed: false, to: email, fileId, sharedLink, error: error.message }; + } + } + + // List all cases straight from Box: walk Potholes/{ward}/Case-* and read each + // case folder's potholeCase metadata (incl. severity breakdown) + its photo file id. + async function listBoxCases() { + const boxConfig = await readBoxConfig(); + const status = computeBoxStatus(boxConfig); + if (!status.configured) return []; + const rootId = status.caseRootFolderId || status.intakeFolderId; + + const listItems = (folderId, label) => + boxRequestJson(`/folders/${folderId}/items?fields=id,name,type&limit=1000`, { label }, boxConfig) + .then((r) => r.entries || []) + .catch(() => []); + + const rootItems = await listItems(rootId, 'list root'); + const potholes = rootItems.find((e) => e.type === 'folder' && e.name === 'Potholes'); + if (!potholes) return []; + + const wards = (await listItems(potholes.id, 'list wards')).filter((e) => e.type === 'folder'); + + // List all wards' case folders in parallel, then fetch each case's metadata + + // items through a bounded worker pool. Sequential calls took 100s+ with ~40 + // cases, which times out behind hosted proxies (e.g. HuggingFace Spaces). + const wardFolderLists = await Promise.all( + wards.map(async (ward) => (await listItems(ward.id, 'list cases')) + .filter((e) => e.type === 'folder') + .map((cf) => ({ ward, cf }))) + ); + const caseFolders = wardFolderLists.flat(); + + const num = (v) => (v === undefined || v === null || v === '' ? null : Number(v)); + const cases = []; + let cursor = 0; + const CONCURRENCY = 8; + async function worker() { + while (cursor < caseFolders.length) { + const { ward, cf } = caseFolders[cursor++]; + let md; + let items; + try { + [md, items] = await Promise.all([ + boxRequestJson(`/folders/${cf.id}/metadata/enterprise/potholeCase`, { label: 'case md' }, boxConfig), + listItems(cf.id, 'case items'), + ]); + } catch (e) { continue; } // no potholeCase metadata -> not a case record + const photo = items.find((e) => e.type === 'file' && /\.(jpe?g|png)$/i.test(e.name)); + cases.push({ + folderId: cf.id, folderName: cf.name, ward: ward.name, + photoFileId: photo ? photo.id : '', + caseId: md.caseId || cf.name.replace(/^Case-/, ''), + status: md.status || '', severityLevel: md.severityLevel || '', + potholeSize: md.potholeSize || '', + district: md.district || '', maintenanceZone: md.maintenanceZone || '', + aiReviewStatus: md.aiReviewStatus || '', reporterContact: md.reporterContact || '', + severityScore: num(md.severityScore0100), address: md.address || '', + submittedAt: md.submittedAt || '', resolvedAt: md.resolvedAt || '', + duplicateStatus: (md.duplicateStatus || '').trim(), + duplicateCount: num(md.duplicateCount) || 0, linkedCaseIds: md.linkedCaseIds || '', + latitude: num(md.latitude), longitude: num(md.longitude), + weatherRisk: md.weatherRisk || '', aiSummary: md.aiSummary || '', + breakdown: { + priorNotice: { score: num(md.priorNoticeScore), max: 30, detail: md.priorNoticeDetail || '' }, + traffic: { score: num(md.trafficScore), max: 25, detail: md.trafficDetail || '' }, + damage: { score: num(md.damageScore), max: 25, detail: md.damageDetail || '' }, + weather: { score: num(md.weatherScore), max: 10, detail: md.weatherDetail || '' }, + }, + }); + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, caseFolders.length || 1) }, worker)); + return cases; + } + + async function fetchOfficialBoundaries() { + const response = await fetchWithRetry(DEFAULT_GIS_BOUNDARY_URL, { + method: 'GET', + headers: { + Accept: 'application/geo+json, application/json;q=0.9, */*;q=0.1', + }, + }, { label: 'Official GIS boundary fetch' }); + + if (!response.ok) { + throw new Error(`Official GIS boundary fetch failed (${response.status}): ${await readErrorMessage(response)}`); + } + + return { + contentType: response.headers.get('content-type') || 'application/json; charset=utf-8', + body: await response.text(), + }; + } + + return { + DEFAULT_GIS_BOUNDARY_URL, + fetchWithRetry, + stripPhotoDataUrl, + getClientConfig, + getStatus, + saveConfig, + resetConfig, + testConnection, + uploadPhoto, + uploadSidecar, + generateAndUploadAuditReport, + markCaseResolvedInBox, + setCaseStatusInBox, + notifyCitizenViaBox, + notifyCloseoutViaBox, + getCaseSubmissionData, + uploadCloseoutReport, + createSignRequest, + uploadCaseArtifact, + uploadGeneratedDoc, + organizeCase, + getFileContent, + searchCase, + listBoxCases, + fetchOfficialBoundaries, + buildSubmissionSidecar, + }; +} + +module.exports = { + createBoxService, + fetchWithRetry, + stripPhotoDataUrl, + DEFAULT_GIS_BOUNDARY_URL, +}; diff --git a/box-upload.js b/box-upload.js new file mode 100644 index 0000000000000000000000000000000000000000..9c3090ceee959493d1c6dc195b3193e7a627e8e5 --- /dev/null +++ b/box-upload.js @@ -0,0 +1,200 @@ +'use strict'; + +const API_PREFIX = '/api'; + +let __boxStatusCache = null; +let __boxStatusFetchedAt = 0; + +function stripPhotoDataUrl(value) { + if (Array.isArray(value)) { + return value.map((item) => stripPhotoDataUrl(item)); + } + + if (!value || typeof value !== 'object') { + return value; + } + + const clone = {}; + for (const [key, child] of Object.entries(value)) { + if (key === 'photoDataUrl') continue; + clone[key] = stripPhotoDataUrl(child); + } + return clone; +} + +async function parseApiResponse(response) { + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + throw new Error(payload?.error || payload?.message || `Request failed with HTTP ${response.status}`); + } + + return payload; +} + +async function apiRequest(path, options = {}) { + const response = await fetch(`${API_PREFIX}${path}`, { + method: options.method || 'GET', + headers: { + ...(options.rawBody ? {} : { 'Content-Type': 'application/json' }), + ...(options.headers || {}), + }, + ...(options.body !== undefined ? { body: options.body } : {}), + }); + + return parseApiResponse(response); +} + +async function getBoxStatus(options = {}) { + const { force = false } = options; + if (__boxStatusCache && !force && (Date.now() - __boxStatusFetchedAt) < 10000) { + return __boxStatusCache; + } + + const payload = await apiRequest('/box/status', { method: 'GET' }); + __boxStatusCache = payload?.status || {}; + __boxStatusFetchedAt = Date.now(); + return __boxStatusCache; +} + +function buildMissingConfigMessage(status = {}) { + if (!status.hasAuth) { + return 'Box authentication is not configured on the backend. Open settings.html and save the Box integration first.'; + } + if (!status.hasIntakeFolderId) { + return 'Incoming intake folder ID is not configured. Open settings.html and save the Box integration first.'; + } + return 'Box is not configured on the backend. Open settings.html and save the Box integration first.'; +} + +async function testBoxConnection() { + try { + const payload = await apiRequest('/box/test-connection', { + method: 'POST', + body: JSON.stringify({}), + }); + __boxStatusCache = null; + __boxStatusFetchedAt = 0; + return payload; + } catch (error) { + return { ok: false, error: error.message }; + } +} + +async function uploadPhoto(file, caseId) { + if (!file) { + throw new Error('Photo file is required.'); + } + + const status = await getBoxStatus(); + if (!status.configured) { + throw new Error(buildMissingConfigMessage(status)); + } + + const payload = await apiRequest(`/box/upload-photo?caseId=${encodeURIComponent(caseId)}`, { + method: 'POST', + rawBody: true, + headers: { + 'Content-Type': file.type || 'application/octet-stream', + }, + body: file, + }); + + return { + fileId: payload.fileId || '', + sharedLink: payload.sharedLink || null, + photoUrl: payload.photoUrl || '', + }; +} + +async function uploadSidecar(metadata, caseId) { + const status = await getBoxStatus(); + if (!status.configured) { + throw new Error(buildMissingConfigMessage(status)); + } + + const payload = await apiRequest('/box/upload-sidecar', { + method: 'POST', + body: JSON.stringify({ + caseId, + metadata: stripPhotoDataUrl(metadata || {}), + }), + }); + + return { + fileId: payload.fileId || '', + sidecarPayload: payload.sidecarPayload || null, + }; +} + +async function submitToBox(photoFile, metadata) { + const providedCaseId = metadata && typeof metadata.caseId === 'string' + ? metadata.caseId.trim() + : ''; + const randomSuffix = Math.random() + .toString(36) + .toUpperCase() + .replace(/[^A-Z]/g, '') + .slice(0, 4) + .padEnd(4, 'X'); + + const caseId = providedCaseId || `PHX-${Date.now()}-${randomSuffix}`; + const submittedAt = metadata?.submittedAt || new Date().toISOString(); + const sanitizedMetadata = stripPhotoDataUrl({ + ...metadata, + caseId, + submittedAt, + }); + + try { + const photoResult = await uploadPhoto(photoFile, caseId); + const sidecarResult = await uploadSidecar({ + ...sanitizedMetadata, + photoFileId: photoResult.fileId || '', + photoUrl: photoResult.photoUrl || '', + }, caseId); + + return { + success: true, + caseId, + photoFileId: photoResult.fileId || '', + sidecarFileId: sidecarResult.fileId || '', + sidecarPayload: sidecarResult.sidecarPayload || sanitizedMetadata, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + getBoxStatus, + testBoxConnection, + uploadPhoto, + uploadSidecar, + submitToBox, + stripPhotoDataUrl, + }; +} + +if (typeof window !== 'undefined') { + window.getBoxStatus = getBoxStatus; + window.testBoxConnection = testBoxConnection; + window.submitToBox = submitToBox; + window.BoxUpload = { + getBoxStatus, + testBoxConnection, + uploadPhoto, + uploadSidecar, + submitToBox, + stripPhotoDataUrl, + }; +} diff --git a/box-workflow.js b/box-workflow.js new file mode 100644 index 0000000000000000000000000000000000000000..ab1d17e17af17b43f690b997ae5808b6104fc330 --- /dev/null +++ b/box-workflow.js @@ -0,0 +1,189 @@ +'use strict'; + +const BOX_WORKFLOW_TEMPLATE = 'pothole_box_ops_v1'; + +function workflowPriorityFromSeverity(severity) { + const normalized = String(severity || '').toLowerCase(); + if (normalized === 'critical') return 'urgent'; + if (normalized === 'high' || normalized === 'large') return 'high'; + if (normalized === 'medium') return 'medium'; + return 'normal'; +} + +function buildInitialWorkflow(report = {}, boxConfig = {}) { + const aiStatus = String(report.aiReviewStatus || '').toLowerCase(); + const aiResult = String(report.aiResult || report.aiPrediction || '').toLowerCase(); + const needsManualReview = aiStatus.includes('manual') || aiStatus.includes('face'); + const needsAttention = aiStatus === 'invalid_image'; + + return { + template: BOX_WORKFLOW_TEMPLATE, + currentStage: 'incoming_detection', + currentStageLabel: 'Incoming Detection', + status: needsManualReview || needsAttention ? 'Under Review' : 'Submitted', + queue: needsManualReview || needsAttention ? 'Manual Review' : 'Ops Intake', + nextAction: needsAttention + ? 'Supervisor must validate the upload before enrichment' + : needsManualReview + ? 'Supervisor must verify the image before enrichment' + : 'Run enrichment and create the case folder', + priority: workflowPriorityFromSeverity(aiResult), + needsManualReview, + triggerFolderId: boxConfig.folderId || '', + triggerFolderName: boxConfig.folderName || 'Incoming Detections', + submittedAt: report.submittedAt || new Date().toISOString(), + lastUpdatedAt: report.submittedAt || new Date().toISOString(), + }; +} + +function buildEnrichedWorkflow(report = {}, enrichment = {}, context = {}) { + const severityLevel = String(enrichment.severityLevel || '').trim() || 'Pending'; + const duplicateCount = enrichment.duplicate?.duplicateCount || 0; + const linkedCases = Array.isArray(enrichment.duplicate?.linkedCaseIds) + ? enrichment.duplicate.linkedCaseIds + : []; + const urgent = severityLevel.toLowerCase() === 'critical'; + + return { + template: BOX_WORKFLOW_TEMPLATE, + currentStage: 'supervisor_review', + currentStageLabel: 'Supervisor Review', + status: 'Under Review', + queue: urgent ? 'Emergency Dispatch' : 'Supervisor Review', + nextAction: urgent + ? 'Assign an emergency crew and dispatch immediately' + : duplicateCount > 0 + ? 'Review duplicate cluster and confirm routing' + : 'Supervisor approve the case and send it to crew routing', + priority: workflowPriorityFromSeverity(severityLevel), + needsManualReview: String(report.aiReviewStatus || '').toLowerCase().includes('manual'), + severityLevel, + severityScore: enrichment.severityScore ?? '', + duplicateCount, + linkedCases: linkedCases.join(', '), + district: enrichment.district?.district || report.district || '', + ward: enrichment.district?.ward || report.ward || '', + maintenanceZone: enrichment.district?.maintenanceZone || report.maintenanceZone || '', + boxPath: context.folderPath || '', + rootFolderId: context.rootFolderId || '', + lastUpdatedAt: enrichment.enrichedAt || new Date().toISOString(), + }; +} + +function flattenWorkflowForBox(workflow = {}) { + return { + workflowTemplate: workflow.template || BOX_WORKFLOW_TEMPLATE, + workflowStage: workflow.currentStage || '', + workflowStageLabel: workflow.currentStageLabel || '', + workflowStatus: workflow.status || '', + workflowQueue: workflow.queue || '', + workflowNextAction: workflow.nextAction || '', + workflowPriority: workflow.priority || '', + workflowNeedsManualReview: workflow.needsManualReview ? 'true' : 'false', + workflowSeverityLevel: workflow.severityLevel || '', + workflowSeverityScore: workflow.severityScore ?? '', + workflowDuplicateCount: workflow.duplicateCount ?? '', + workflowLinkedCases: workflow.linkedCases || '', + workflowWard: workflow.ward || '', + workflowDistrict: workflow.district || '', + workflowMaintenanceZone: workflow.maintenanceZone || '', + workflowBoxPath: workflow.boxPath || '', + workflowLastUpdatedAt: workflow.lastUpdatedAt || '', + workflowTriggerFolderId: workflow.triggerFolderId || workflow.rootFolderId || '', + workflowTriggerFolderName: workflow.triggerFolderName || '', + }; +} + +function getWorkflowInputFields() { + return [ + { key: 'caseId', source: 'Citizen upload', purpose: 'Primary record key used across Box, ops review, and tracking.' }, + { key: 'submittedAt', source: 'Citizen upload', purpose: 'Timestamp for intake SLAs and queue sorting.' }, + { key: 'photoFileId', source: 'Box upload', purpose: 'Original pothole photo file to preview or move into the case folder.' }, + { key: 'sidecarFileId', source: 'Box upload', purpose: 'Original JSON sidecar used as the workflow payload.' }, + { key: 'address', source: 'Citizen upload', purpose: 'Road location shown to reviewers and crew dispatch.' }, + { key: 'areaLabel', source: 'Reverse geocode', purpose: 'Human-friendly locality label shown before official ward matching succeeds.' }, + { key: 'ward', source: 'GIS or reverse geocode', purpose: 'Routes the case into the correct Box subfolder and maintenance area.' }, + { key: 'district', source: 'GIS mapping', purpose: 'Maps the case to the review or engineering district.' }, + { key: 'maintenanceZone', source: 'GIS mapping', purpose: 'Directs dispatch to the correct crew zone.' }, + { key: 'duplicateStatus', source: 'Ops enrichment', purpose: 'Simple duplicate/new label used by external workflow decisions.' }, + { key: 'weatherStatus', source: 'Ops enrichment', purpose: 'Weather risk status stored with the case for triage and reporting.' }, + { key: 'potholeSize', source: 'Citizen upload AI', purpose: 'Human-readable pothole size used before and after enrichment.' }, + { key: 'aiReviewStatus', source: 'Citizen upload AI', purpose: 'Determines whether the case should enter manual review first.' }, + { key: 'aiResult', source: 'Citizen upload AI', purpose: 'Initial pothole size signal before enrichment recalculates severity.' }, + { key: 'severityScore', source: 'Ops enrichment', purpose: 'Numeric urgency score for routing, triage, and reporting.' }, + { key: 'severityLevel', source: 'Ops enrichment', purpose: 'Human-readable priority label for Box tasks and dashboards.' }, + { key: 'isDuplicate', source: 'Ops enrichment', purpose: 'Flags repeat reports so teams can merge or escalate clusters.' }, + { key: 'workflowStage', source: 'Workflow metadata', purpose: 'Stable machine field for the current lifecycle stage.' }, + { key: 'workflowNextAction', source: 'Workflow metadata', purpose: 'Plain-language instruction shown to the next owner.' }, + ]; +} + +function getWorkflowStages() { + return [ + { + id: 'incoming_detection', + title: '1. Citizen intake and Box file storage', + use: 'The portal uploads the photo and JSON sidecar to Box, then upserts the same case into the backend queue.', + input: 'Folder ID from Settings, caseId, submittedAt, address, location, aiReviewStatus, potholeSize', + }, + { + id: 'supervisor_review', + title: '2. Database-owned enrichment and review', + use: 'Ops Review and backend services calculate GIS, duplicates, weather, and severity, then save enriched artifacts back into Box.', + input: 'photoFileId, sidecarFileId, ward, district, maintenanceZone, duplicateStatus, weatherStatus, severityScore', + }, + { + id: 'crew_dispatch', + title: '3. Database dispatch with Box-backed artifacts', + use: 'The backend records supervisor approval, authority routing, and crew dispatch while Box stores the generated notices and assignment files.', + input: 'severityLevel, duplicateStatus, workflowPriority, workflowStatus, maintenanceZone, assignedCrew', + }, + { + id: 'resolved', + title: '4. Database closeout with Box proof storage', + use: 'Crew forms, after photos, reports, and audit outputs are saved by the backend and written back into Box for shared recordkeeping.', + input: 'resolvedAt, resolutionNotes, afterPhotoFileId, laborHours, materialsUsed, workflowStatus', + }, + ]; +} + +function buildWorkflowBlueprint(boxConfig = {}) { + return { + template: BOX_WORKFLOW_TEMPLATE, + triggerFolderId: boxConfig.folderId || '', + triggerFolderName: boxConfig.folderName || 'Incoming Detections', + automationUses: [ + 'Box as the content repository for intake, case folders, proof files, and reports', + 'JSON sidecar payload uploaded by the citizen portal', + 'Enriched metadata and readable summaries written back after backend processing', + 'Backend and app screens own approvals, dispatch, authority notifications, and closeout', + ], + inputFields: getWorkflowInputFields(), + stages: getWorkflowStages(), + metadataKeys: Object.keys(flattenWorkflowForBox({})), + }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + BOX_WORKFLOW_TEMPLATE, + buildInitialWorkflow, + buildEnrichedWorkflow, + flattenWorkflowForBox, + getWorkflowInputFields, + getWorkflowStages, + buildWorkflowBlueprint, + }; +} + +if (typeof window !== 'undefined') { + window.BoxWorkflow = { + BOX_WORKFLOW_TEMPLATE, + buildInitialWorkflow, + buildEnrichedWorkflow, + flattenWorkflowForBox, + getWorkflowInputFields, + getWorkflowStages, + buildWorkflowBlueprint, + }; +} diff --git a/case-report.html b/case-report.html new file mode 100644 index 0000000000000000000000000000000000000000..e9bac3817c52fc3dae98a0a54d251301ddfd269a --- /dev/null +++ b/case-report.html @@ -0,0 +1,303 @@ + + + + + + Before / After Report โ€” PotholeIQ + + + + + + +
+
P
PotholeIQ
+
+ โ† Ops Review + +
+
+ +
+
+
Loading case reportโ€ฆ
+
+
+ + + + + diff --git a/command-center.html b/command-center.html new file mode 100644 index 0000000000000000000000000000000000000000..dd48ea868d220214dbd2c5f352ec8d37cd732228 --- /dev/null +++ b/command-center.html @@ -0,0 +1,391 @@ + + + + + +PotholeIQ โ€” Command Center + + + + +
+
+ +
+

PotholeIQ โ€” Command Center

+
Operations view ยท Philadelphia
+
+
+
+ + + +
+
+ +
+ +
+
+

Pothole Map โ€” severity by location

+
+
+
+

Severity Distribution

+
+

Status Pipeline

+
+
+ +
+

Cases by Ward

+

Pothole Size

+

Submissions Over Time

+
+ +
+

Active Cases โ€” severity breakdown

+ +
+ +
+
Severity
+
+
Status
+
+
Ward
+
+
+
+ +
+ + + + + + + + + + + diff --git a/config-store.js b/config-store.js new file mode 100644 index 0000000000000000000000000000000000000000..89fee2454b395fbb24c9cbb5d164d0b508ee5af0 --- /dev/null +++ b/config-store.js @@ -0,0 +1,223 @@ +'use strict'; + +const fs = require('fs/promises'); +const path = require('path'); + +const CONFIG_FILE_NAME = 'config.json'; + +const DEFAULT_BOX_CONFIG = { + authMode: 'oauth_refresh', + accessToken: '', + accessTokenExpiresAt: '', + refreshToken: '', + clientId: '', + clientSecret: '', + subjectType: 'enterprise', + subjectId: '', + intakeFolderId: '', + caseRootFolderId: '', + metadataScope: '', + metadataTemplateKey: '', + updatedAt: '', +}; + +const DEFAULT_APP_CONFIG = { + box: { ...DEFAULT_BOX_CONFIG }, +}; + +function nowIso() { + return new Date().toISOString(); +} + +function cloneDefaults() { + return JSON.parse(JSON.stringify(DEFAULT_APP_CONFIG)); +} + +function normalizeString(value) { + return String(value ?? '').trim(); +} + +function maskSecret(secret) { + const normalized = normalizeString(secret); + if (!normalized) return ''; + if (normalized.length <= 8) return `${normalized.slice(0, 2)}...${normalized.slice(-2)}`; + return `${normalized.slice(0, 4)}...${normalized.slice(-4)}`; +} + +function sanitizeAuthMode(value, fallback = DEFAULT_BOX_CONFIG.authMode) { + const normalized = normalizeString(value).toLowerCase(); + if (['access_token', 'oauth_refresh', 'client_credentials'].includes(normalized)) { + return normalized; + } + return fallback; +} + +function sanitizeSubjectType(value, fallback = DEFAULT_BOX_CONFIG.subjectType) { + const normalized = normalizeString(value).toLowerCase(); + if (normalized === 'user' || normalized === 'enterprise') { + return normalized; + } + return fallback; +} + +function mergeBoxConfig(existing = {}, incoming = {}, options = {}) { + const next = { + ...DEFAULT_BOX_CONFIG, + ...(existing || {}), + }; + + if (options.reset) { + return { + ...DEFAULT_BOX_CONFIG, + updatedAt: nowIso(), + }; + } + + if (Object.prototype.hasOwnProperty.call(incoming, 'authMode')) { + next.authMode = sanitizeAuthMode(incoming.authMode, next.authMode); + } + + if (Object.prototype.hasOwnProperty.call(incoming, 'subjectType')) { + next.subjectType = sanitizeSubjectType(incoming.subjectType, next.subjectType); + } + + const copyFields = [ + 'clientId', + 'subjectId', + 'intakeFolderId', + 'caseRootFolderId', + 'metadataScope', + 'metadataTemplateKey', + ]; + + for (const field of copyFields) { + if (Object.prototype.hasOwnProperty.call(incoming, field)) { + next[field] = normalizeString(incoming[field]); + } + } + + if (Object.prototype.hasOwnProperty.call(incoming, 'accessTokenExpiresAt')) { + next.accessTokenExpiresAt = normalizeString(incoming.accessTokenExpiresAt); + } + + const secretFieldMap = [ + ['accessToken', 'clearAccessToken'], + ['refreshToken', 'clearRefreshToken'], + ['clientSecret', 'clearClientSecret'], + ]; + + for (const [field, clearField] of secretFieldMap) { + if (incoming[clearField]) { + next[field] = ''; + if (field === 'accessToken') next.accessTokenExpiresAt = ''; + continue; + } + + if (!Object.prototype.hasOwnProperty.call(incoming, field)) continue; + const normalized = normalizeString(incoming[field]); + if (normalized) { + next[field] = normalized; + if (field === 'accessToken' && !incoming.accessTokenExpiresAt) { + next.accessTokenExpiresAt = ''; + } + } + } + + next.updatedAt = nowIso(); + return next; +} + +function sanitizeBoxConfigForClient(box = {}) { + const merged = { ...DEFAULT_BOX_CONFIG, ...(box || {}) }; + return { + authMode: sanitizeAuthMode(merged.authMode), + clientId: normalizeString(merged.clientId), + subjectType: sanitizeSubjectType(merged.subjectType), + subjectId: normalizeString(merged.subjectId), + intakeFolderId: normalizeString(merged.intakeFolderId), + caseRootFolderId: normalizeString(merged.caseRootFolderId), + metadataScope: normalizeString(merged.metadataScope), + metadataTemplateKey: normalizeString(merged.metadataTemplateKey), + accessTokenPreview: maskSecret(merged.accessToken), + refreshTokenPreview: maskSecret(merged.refreshToken), + clientSecretPreview: maskSecret(merged.clientSecret), + hasAccessToken: !!normalizeString(merged.accessToken), + hasRefreshToken: !!normalizeString(merged.refreshToken), + hasClientSecret: !!normalizeString(merged.clientSecret), + accessTokenExpiresAt: normalizeString(merged.accessTokenExpiresAt), + updatedAt: normalizeString(merged.updatedAt), + }; +} + +function createConfigStore({ dataDir }) { + const configPath = path.join(dataDir, CONFIG_FILE_NAME); + let writeQueue = Promise.resolve(); + + async function ensureConfigFile() { + await fs.mkdir(dataDir, { recursive: true }); + try { + await fs.access(configPath); + } catch { + await fs.writeFile(configPath, JSON.stringify(cloneDefaults(), null, 2), 'utf8'); + } + } + + async function readConfig() { + await ensureConfigFile(); + const raw = await fs.readFile(configPath, 'utf8'); + const parsed = JSON.parse(raw); + return { + ...cloneDefaults(), + ...(parsed || {}), + box: { + ...DEFAULT_BOX_CONFIG, + ...((parsed && parsed.box) || {}), + }, + }; + } + + async function writeConfig(nextConfig) { + const normalized = { + ...cloneDefaults(), + ...(nextConfig || {}), + box: { + ...DEFAULT_BOX_CONFIG, + ...((nextConfig && nextConfig.box) || {}), + }, + }; + const tmpPath = `${configPath}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(normalized, null, 2), 'utf8'); + await fs.rename(tmpPath, configPath); + return normalized; + } + + async function queueMutation(mutator) { + const task = writeQueue.then(async () => { + const current = await readConfig(); + const next = await mutator(current); + const written = await writeConfig(next || current); + return written; + }); + writeQueue = task.catch(() => undefined); + return task; + } + + return { + configPath, + readConfig, + writeConfig, + queueMutation, + mergeBoxConfig, + sanitizeBoxConfigForClient, + defaults: { + box: { ...DEFAULT_BOX_CONFIG }, + }, + }; +} + +module.exports = { + createConfigStore, + mergeBoxConfig, + sanitizeBoxConfigForClient, + DEFAULT_BOX_CONFIG, +}; diff --git a/crew-closeout.html b/crew-closeout.html new file mode 100644 index 0000000000000000000000000000000000000000..4fa9fcc675059bf1a3a04574462bf41f1b3af533 --- /dev/null +++ b/crew-closeout.html @@ -0,0 +1,602 @@ + + + + + + Crew Closeout โ€” PotholeIQ + + + + + + + + + +
+
+
+
+
Assigned Case Snapshot
+
Select an assigned case, verify the location and severity context, then submit the field proof and closeout details.
+
+
+
+ + +
+
+
+
Status
+
No case selected yet.
+
+
+
+
+ +
+
+
Crew Completion Form
+
This records the repair proof outside Box, then saves the after-photo, before/after report, and audit artifacts back into the case folder in Box.
+
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
Total Spend (labor + materials)
+
Enter labor hours, rate, and materials cost.
+
+
$0.00
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
This uploads directly into the case folder in Box before the closeout record is saved.
+
+
+ +
+ + +
Every closeout already shares the report with the default recipients configured for the team (via Box). Add any extra one-off recipients here, separated by commas. One-time invite: each person is invited once to the Box โ€œCloseout Reportsโ€ folder, then keeps standing access โ€” turn on that folderโ€™s notifications in Box to get one clean email per closeout. Requires Box to be connected.
+
+ +
+ +
+
+ +
+
+
+
+
+ + + + + diff --git a/dashcam-demo.html b/dashcam-demo.html new file mode 100644 index 0000000000000000000000000000000000000000..e653b139377eafcfe6d89e567b9362029f743bf0 --- /dev/null +++ b/dashcam-demo.html @@ -0,0 +1,931 @@ + + + + + + Dashcam Capture Mode - City Road Reporting + + + + + + + + + + + + + + +
+ +
+ + + + + +
+ + +
+ + +
+
+ +
+

Dashcam Mode

+ Automatic Moving Vehicle Capture +
+
+
+ +
+
+ + +
+ + +
+
+ Captured + 0 +
+
+ GPS Speed + 0mph +
+
+ Latency + --ms +
+
+ + +
+
Continuous Scanning
+
Searching...
+
+
+
+
Sample Rate: 1.0s
+
+ + +
+
+ GPS Lock +
+ + No Signal +
+
+
+ Coordinates + Waiting... +
+
+ Precision + -- +
+
+ Detected Ward + -- +
+
+ +
+ + +
+ + + + + + +
+
+ +
+ + + + + +
+
+
+

Dashcam Setup

+ +
+ + +
+
+ AI Scanning Rate + 1.0s +
+ +

How often the camera feeds a frame to the AI. Lower means more frequent checks, consuming more battery.

+
+ + +
+
+ Capture Threshold + 72% +
+ +

Minimum confidence percentage required to auto-record a pothole detection.

+
+ + +
+ + +

Roboflow draws real bounding boxes around each pothole. Teachable Machine classifies the whole frame.

+
+ + +
+ + +
+ + +
+

Get a free publishable key at roboflow.com (Settings โ†’ API Keys). Find a model id + version on any Roboflow Universe pothole project, or train your own. Runs fully in-browser.

+
+ + +
+ + +

Load custom Google Teachable Machine image classification models.

+
+ + +
+
+ + + + + + + + + + + + + + + diff --git a/dashcam-demo.js b/dashcam-demo.js new file mode 100644 index 0000000000000000000000000000000000000000..00401a04054865f365bd1f2f11acef70fbce081c --- /dev/null +++ b/dashcam-demo.js @@ -0,0 +1,989 @@ +/** + * dashcam-demo.js โ€” Real-Time Mobile Capture Controller + * Performs edge-inference AI classification on continuous video feed, + * tracks live coordinates, and handles spatial deduplication. + */ + +'use strict'; + +(function () { + // โ”€โ”€โ”€ CONFIGURATION & LOCAL STORAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const CONFIG = { + engine: localStorage.getItem('dashcam_engine') || 'roboflow', // 'roboflow' | 'teachable' + modelUrl: localStorage.getItem('tm_model_url') || 'https://teachablemachine.withgoogle.com/models/3HQTi9DMo/', + rfKey: localStorage.getItem('roboflow_publishable_key') || '', + rfModel: localStorage.getItem('roboflow_model_id') || 'pothole-detection-yolov8-ehkp9', + rfVersion: parseInt(localStorage.getItem('roboflow_model_version') || '1', 10), + sampleRate: parseFloat(localStorage.getItem('dashcam_sample_rate') || '1.0'), + threshold: parseFloat(localStorage.getItem('dashcam_threshold') || '72'), + soundEnabled: localStorage.getItem('dashcam_sound_enabled') !== 'false' + }; + + // โ”€โ”€โ”€ APPLICATION STATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const state = { + stream: null, + tmModel: null, + rfModel: null, // Loaded Roboflow detection model + gpsWatcherId: null, + currentGPS: null, + lastDetections: [], // Cache for spatial deduplication { lat, lng, timestamp } + sessionCount: 0, + aiTimer: null, + isProcessing: false, + audioCtx: null + }; + + // โ”€โ”€โ”€ DOM REFERENCES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const DOM = { + video: document.getElementById('webcam-feed'), + canvas: document.getElementById('frame-canvas'), + overlay: document.getElementById('alert-overlay'), + boxOverlay: document.getElementById('detection-overlay'), + + // Stats HUD + statsTotal: document.getElementById('stats-total-potholes'), + statsSpeed: document.getElementById('stats-speed'), + statsLatency: document.getElementById('stats-ai-latency'), + + // Detection Box HUD + aiBox: document.getElementById('ai-detection-box'), + aiStatusText: document.getElementById('ai-hud-status-text'), + aiResultLabel: document.getElementById('ai-hud-result-label'), + aiBar: document.getElementById('ai-confidence-bar'), + aiMetaText: document.getElementById('ai-hud-meta-text'), + + // Location HUD + gpsBadge: document.getElementById('gps-lock-badge'), + gpsCoords: document.getElementById('hud-gps-coordinates'), + gpsPrecision: document.getElementById('hud-gps-precision'), + detectedWard: document.getElementById('hud-detected-ward'), + + // Footer Controls + btnSound: document.getElementById('btn-sound-toggle'), + btnGallery: document.getElementById('btn-gallery-toggle'), + drawerCount: document.getElementById('stats-drawer-count'), + + // Drawer Gallery + drawer: document.getElementById('gallery-drawer'), + drawerCountTotal: document.getElementById('drawer-count-badge-total'), + drawerEmpty: document.getElementById('drawer-empty-state'), + galleryGrid: document.getElementById('gallery-grid'), + btnClearSession: document.getElementById('btn-clear-session-action'), + + // Settings Panel + settingsOverlay: document.getElementById('settings-overlay'), + btnSettingsOpen: document.getElementById('btn-settings-open'), + btnSettingsClose: document.getElementById('btn-settings-close'), + btnSettingsApply: document.getElementById('btn-settings-apply'), + + sliderRate: document.getElementById('settings-rate'), + sliderThreshold: document.getElementById('settings-threshold'), + inputModelUrl: document.getElementById('settings-model-url'), + selectEngine: document.getElementById('settings-engine'), + inputRfKey: document.getElementById('settings-rf-key'), + inputRfModel: document.getElementById('settings-rf-model'), + inputRfVersion: document.getElementById('settings-rf-version'), + groupRoboflow: document.getElementById('settings-roboflow-group'), + groupTeachable: document.getElementById('settings-teachable-group'), + + labelRate: document.getElementById('settings-label-rate'), + labelThreshold: document.getElementById('settings-label-threshold'), + + btnExit: document.getElementById('btn-exit') + }; + + // โ”€โ”€โ”€ INIT SOUND (WEB AUDIO API) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + function initAudioContext() { + if (!state.audioCtx) { + state.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } + if (state.audioCtx.state === 'suspended') { + state.audioCtx.resume(); + } + } + + function playAlertBeep() { + if (!CONFIG.soundEnabled) return; + try { + initAudioContext(); + const ctx = state.audioCtx; + + // Dual-tone high-frequency notification beep + const osc1 = ctx.createOscillator(); + const osc2 = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5 note + osc1.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.15); + + osc2.type = 'triangle'; + osc2.frequency.setValueAtTime(440, ctx.currentTime); // A4 note harmony + + gainNode.gain.setValueAtTime(0.25, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); + + osc1.connect(gainNode); + osc2.connect(gainNode); + gainNode.connect(ctx.destination); + + osc1.start(); + osc2.start(); + + osc1.stop(ctx.currentTime + 0.2); + osc2.stop(ctx.currentTime + 0.2); + } catch (e) { + console.warn('[Sound] Web Audio play failed:', e); + } + } + + // โ”€โ”€โ”€ DYNAMIC SETTINGS SYNC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + function syncSettingsUI() { + DOM.sliderRate.value = CONFIG.sampleRate; + DOM.sliderThreshold.value = CONFIG.threshold; + DOM.inputModelUrl.value = CONFIG.modelUrl; + + if (DOM.selectEngine) DOM.selectEngine.value = CONFIG.engine; + if (DOM.inputRfKey) DOM.inputRfKey.value = CONFIG.rfKey; + if (DOM.inputRfModel) DOM.inputRfModel.value = CONFIG.rfModel; + if (DOM.inputRfVersion) DOM.inputRfVersion.value = CONFIG.rfVersion; + toggleEngineSettingGroups(); + + DOM.labelRate.textContent = CONFIG.sampleRate + 's'; + DOM.labelThreshold.textContent = CONFIG.threshold + '%'; + + DOM.aiMetaText.textContent = `Sample Rate: ${CONFIG.sampleRate}s`; + + if (CONFIG.soundEnabled) { + DOM.btnSound.classList.remove('muted'); + DOM.btnSound.textContent = '๐Ÿ”Š'; + } else { + DOM.btnSound.classList.add('muted'); + DOM.btnSound.textContent = '๐Ÿ”‡'; + } + } + + function toggleEngineSettingGroups() { + const engine = DOM.selectEngine ? DOM.selectEngine.value : CONFIG.engine; + if (DOM.groupRoboflow) DOM.groupRoboflow.style.display = engine === 'roboflow' ? 'block' : 'none'; + if (DOM.groupTeachable) DOM.groupTeachable.style.display = engine === 'teachable' ? 'block' : 'none'; + } + + // โ”€โ”€โ”€ CAMERA ACCESS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + async function startCamera() { + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + } + + const constraints = { + audio: false, + video: { + facingMode: { ideal: 'environment' }, // Request back camera primarily + width: { ideal: 1280 }, + height: { ideal: 720 } + } + }; + + try { + state.stream = await navigator.mediaDevices.getUserMedia(constraints); + DOM.video.srcObject = state.stream; + console.info('[Camera] Rear-facing environment camera stream loaded.'); + } catch (err) { + console.warn('[Camera] Exact environment mode failed, falling back to basic camera constraints.', err); + try { + state.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + DOM.video.srcObject = state.stream; + } catch (fallbackErr) { + console.error('[Camera] WebRTC access denied or unavailable.', fallbackErr); + alert('Could not access device camera. Please verify camera permissions in settings.'); + } + } + } + + // โ”€โ”€โ”€ AI INFERENCE LOOP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Lazily inject a + + + + + + + + + + +
+ + + + + +
+
+ + + + + +
+ Click anywhere on the map to Add a Pothole. Numbered markers show stop order for each crew route. +
+
+ +
+ + +
+
+
+

+ + Crew Deployment Configuration +

+

Initialize maintenance zones, configure crew groupings, and auto-generate optimized route schedules to patch pothole incidents.

+
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
3 Groups
+
+
+ +
+ 1 CREW + 2 + 3 + 4 + 5 CREWS +
+
+
+ +
+ +
+
+ + + + + +
+ Dispatch system active +
+ + + + + + diff --git a/enrichment.js b/enrichment.js new file mode 100644 index 0000000000000000000000000000000000000000..4f2a685978e693e7a4b531578bd7eab6372841fa --- /dev/null +++ b/enrichment.js @@ -0,0 +1,373 @@ +/** + * enrichment.js + * AI Enrichment Engine for the Pothole Ops Console. + * + * Calculates a severity score (0โ€“100) across 5 weighted factors: + * 1. 311 / Prior Notice (30 pts) + * 2. Traffic / Road Class (25 pts) + * 3. Damage Type (AI result) (25 pts) + * 4. Weekly Trend/Recurrence (20 pts) + * 5. Weather Risk (bonus) (up to +10 pts) + * + * Also performs: + * โ€“ GPS-radius duplicate detection using the Haversine formula + * โ€“ Ward / District / Maintenance Zone heuristic mapping + * โ€“ OpenWeatherMap 5-day forecast integration + */ + +'use strict'; + +// โ”€โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const ENRICH = { + WEATHER_KEY : 'faa0f60f5fb9bf7b075bd90cfa40829b', + WEATHER_URL : 'https://api.openweathermap.org/data/2.5/forecast', + EARTH_R : 6371000, // metres + DUP_RADIUS_M : 80, // GPS duplicate search radius + FACTOR_WEIGHTS : { priorNotice: 30, traffic: 25, damage: 25, weather: 10 }, +}; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchWithRetry(url, options = {}, retryOptions = {}) { + const { + retries = 2, + baseDelayMs = 700, + retryStatuses = [408, 429, 500, 502, 503, 504], + label = 'request', + } = retryOptions; + + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(url, options); + if (!retryStatuses.includes(response.status) || attempt === retries) { + return response; + } + + const retryAfter = response.headers.get('retry-after'); + const retryDelayMs = retryAfter + ? Number(retryAfter) * 1000 || baseDelayMs * (2 ** attempt) + : baseDelayMs * (2 ** attempt); + console.warn(`[Enrichment] ${label} returned ${response.status}; retrying in ${retryDelayMs}ms`); + await sleep(retryDelayMs); + } catch (error) { + lastError = error; + if (attempt === retries) throw error; + + const retryDelayMs = baseDelayMs * (2 ** attempt); + console.warn(`[Enrichment] ${label} failed (${error.message}); retrying in ${retryDelayMs}ms`); + await sleep(retryDelayMs); + } + } + + throw lastError || new Error(`${label} failed.`); +} + +// โ”€โ”€โ”€ Haversine distance (metres) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function haversine(lat1, lon1, lat2, lon2) { + const R = ENRICH.EARTH_R; + const ฯ†1 = lat1 * Math.PI / 180, ฯ†2 = lat2 * Math.PI / 180; + const ฮ”ฯ† = (lat2 - lat1) * Math.PI / 180; + const ฮ”ฮป = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(ฮ”ฯ†/2)**2 + Math.cos(ฯ†1) * Math.cos(ฯ†2) * Math.sin(ฮ”ฮป/2)**2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +// โ”€โ”€โ”€ Helper: get cases nearby a GPS coordinate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function nearbyCases(targetCase, allCases, radiusM = ENRICH.DUP_RADIUS_M) { + const loc = (targetCase.report || targetCase).location || {}; + if (!loc.lat || !loc.lng) return []; + return allCases.filter(c => { + if (c.caseId === targetCase.caseId) return false; + const l = (c.report || c).location || {}; + if (!l.lat || !l.lng) return false; + return haversine(loc.lat, loc.lng, l.lat, l.lng) <= radiusM; + }); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// FACTOR 1 โ€” 311 / Prior Notice (max 30 pts) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function scorePriorNotice(targetCase, allCases) { + const nearby = nearbyCases(targetCase, allCases); + if (nearby.length === 0) return { score: 0, max: 30, detail: 'No prior reports at this location' }; + if (nearby.length === 1) return { score: 12, max: 30, detail: '1 prior report found within 80 m' }; + if (nearby.length === 2) return { score: 22, max: 30, detail: `2 prior reports โ€” notable prior notice` }; + return { score: 30, max: 30, detail: `${nearby.length} prior reports โ€” high prior notice / strong legal weight` }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// FACTOR 2 โ€” Traffic Volume / Road Classification (max 25 pts) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const ROAD_KEYWORDS = { + critical : ['highway', 'expressway', 'interstate', 'freeway', ' i-', 'route ', 'school', 'hospital', 'emergency', 'fire station'], + high : ['blvd', 'boulevard', 'ave', 'avenue', 'broad', 'market', 'ridge', 'girard', + 'frankford', 'baltimore', 'cheltenham', 'aramingo', 'bustleton', 'roosevelt', + 'pattison', 'passyunk', 'germantown', 'lancaster', 'torresdale'], + low : [' ln', ' lane', ' pl ', ' place', ' ct ', ' court', ' ter ', ' terr', ' cir ', ' alley', ' mews', ' close'], +}; + +function scoreTrafficVolume(address) { + if (!address) return { score: 12, max: 25, detail: 'Unknown road type โ€” moderate traffic assumed' }; + const a = address.toLowerCase(); + if (ROAD_KEYWORDS.critical.some(k => a.includes(k))) return { score: 25, max: 25, detail: 'Critical infrastructure / high-traffic route (school, hospital, arterial)' }; + if (ROAD_KEYWORDS.high.some(k => a.includes(k))) return { score: 18, max: 25, detail: 'Major arterial or collector road โ€” high traffic volume' }; + if (ROAD_KEYWORDS.low.some(k => a.includes(k))) return { score: 6, max: 25, detail: 'Residential / low-traffic street' }; + return { score: 12, max: 25, detail: 'Local collector street โ€” moderate traffic' }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// FACTOR 3 โ€” Type & Physical Characteristics (AI Result) (max 25 pts) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function scoreDamageType(aiResult) { + const t = (aiResult || '').toLowerCase(); + if (t.includes('large')) return { score: 25, max: 25, detail: 'Large โ€” bowl-shaped, severe / advanced damage affecting ride and safety' }; + if (t.includes('medium')) return { score: 15, max: 25, detail: 'Medium โ€” noticeable damage, not yet vehicle-threatening' }; + if (t.includes('small')) return { score: 6, max: 25, detail: 'Small โ€” initial / emerging crack or surface defect' }; + return { score: 10, max: 25, detail: 'Unclassified โ€” moderate severity assumed pending re-review' }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// FACTOR 4 โ€” Weekly Comparison / Trend / Recurrence (max 20 pts) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function scoreTrend(targetCase, allCases) { + const nearby = nearbyCases(targetCase, allCases); + if (!nearby.length) return { score: 0, max: 20, detail: 'New detection โ€” no prior report at this location', isNew: true }; + + const now = Date.now(); + const aged = nearby.map(c => now - (c.ts || 0)); + const min7 = aged.filter(a => a < 7 * 86400000).length; + const min30 = aged.filter(a => a < 30 * 86400000).length; + const min90 = aged.filter(a => a < 90 * 86400000).length; + + if (min7 > 0) return { score: 20, max: 20, detail: `Repeat report within 7 days โ€” rapid deterioration detected`, repeatCount: min7 }; + if (min30 > 0) return { score: 14, max: 20, detail: `Repeat report within 30 days โ€” worsening trend confirmed`, repeatCount: min30 }; + if (min90 > 0) return { score: 8, max: 20, detail: `Repeat report in 30โ€“90 day window โ€” recurring known issue`, repeatCount: min90 }; + return { score: 4, max: 20, detail: 'Prior report exists beyond 90 days โ€” historical pattern', repeatCount: nearby.length }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// FACTOR 5 โ€” Weather Risk (max +10 pts bonus) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function scoreWeather(lat, lng) { + const none = { score: 0, max: 10, detail: 'No GPS โ€” weather risk unavailable', data: null }; + if (!lat || !lng) return none; + + try { + // Resolve OpenWeatherMap key dynamically from localStorage with fallback to default key + const savedKey = typeof localStorage !== 'undefined' ? localStorage.getItem('weather_api_key') : null; + const activeKey = (savedKey && savedKey.trim()) ? savedKey.trim() : ENRICH.WEATHER_KEY; + + const url = `${ENRICH.WEATHER_URL}?lat=${lat}&lon=${lng}&appid=${activeKey}&units=metric&cnt=16`; + const res = await fetchWithRetry(url, {}, { + label: 'OpenWeather forecast fetch', + }); + if (!res.ok) throw new Error(`Weather API returned ${res.status}`); + const json = await res.json(); + + const forecasts = json.list || []; + const maxPop = Math.max(...forecasts.map(f => f.pop || 0)); + const maxRain = Math.max(...forecasts.map(f => (f.rain?.['3h'] || 0) + (f.snow?.['3h'] || 0))); + const minTemp = Math.min(...forecasts.map(f => f.main.temp_min)); + const maxTemp = Math.max(...forecasts.map(f => f.main.temp_max)); + const freezeThaw = minTemp <= 2 && maxTemp >= 2; + const heavyRain = maxPop > 0.70 || maxRain > 10; + const modRain = maxPop > 0.40 || maxRain > 3; + + const data = { + condition : forecasts[0]?.weather?.[0]?.description || 'Unknown', + icon : forecasts[0]?.weather?.[0]?.icon, + popPercent : Math.round(maxPop * 100), + maxRainMm : maxRain.toFixed(1), + minTempC : minTemp.toFixed(1), + maxTempC : maxTemp.toFixed(1), + freezeThaw, + cityName : json.city?.name || '', + }; + + if (freezeThaw && heavyRain) return { score: 10, max: 10, detail: 'Freeze-thaw cycle + heavy precipitation โ€” extreme pothole expansion risk', data, riskLevel: 'critical' }; + if (freezeThaw) return { score: 8, max: 10, detail: 'Freeze-thaw cycle predicted โ€” high expansion risk via water infiltration', data, riskLevel: 'high' }; + if (heavyRain) return { score: 7, max: 10, detail: `Heavy rain/snow forecast (${Math.round(maxPop*100)}% prob.) โ€” accelerated deterioration`, data, riskLevel: 'high' }; + if (modRain) return { score: 4, max: 10, detail: `Moderate precipitation expected (${Math.round(maxPop*100)}% prob.) โ€” moderate risk`, data, riskLevel: 'moderate' }; + return { score: 0, max: 10, detail: 'Dry / stable forecast in 48 h โ€” minimal weather risk', data, riskLevel: 'low' }; + + } catch (e) { + console.warn('[Enrichment] Weather fetch failed:', e.message); + return { score: 0, max: 10, detail: `Weather unavailable: ${e.message}`, data: null, error: true }; + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// GPS DUPLICATE CHECK +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function checkDuplicates(targetCase, allCases, radiusM = ENRICH.DUP_RADIUS_M) { + const nearby = nearbyCases(targetCase, allCases, radiusM); + return { + isDuplicate : nearby.length > 0, + duplicateCount : nearby.length, + linkedCaseIds : nearby.map(c => c.caseId), + radiusM, + effectiveSeverityBoost: nearby.length >= 3 ? 15 : nearby.length >= 1 ? 8 : 0, + }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// WARD / DISTRICT MAPPING (heuristic โ€” GIS integration deferred) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function mapDistrict(ward, address) { + // Attempt to parse ward number + const wardNum = ward ? parseInt((ward.match(/\d+/) || [])[0] || '0', 10) : 0; + // Maintenance zones: Aโ€“F cycling across wards + const zoneLetters = ['A','B','C','D','E','F']; + const zone = wardNum > 0 ? zoneLetters[(wardNum - 1) % 6] : 'X'; + // Quadrant-based district (Philadelphia has 10 police districts โ€” rough approximation) + const district = wardNum > 0 ? `District-${String(wardNum % 10 + 1).padStart(2,'0')}` : 'District-Pending'; + return { + district, + ward : ward || 'Ward-Unknown', + maintenanceZone: `Zone-${zone}`, + gisSource : 'heuristic', + }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// SEVERITY LEVEL LABEL +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function severityLevel(score) { + if (score >= 76) return { level: 'Critical', color: '#E53E3E', bg: '#FEF2F2', border: '#FECACA' }; + if (score >= 56) return { level: 'High', color: '#C2410C', bg: '#FFF7ED', border: '#FED7AA' }; + if (score >= 31) return { level: 'Medium', color: '#B97700', bg: '#FFF8E6', border: '#FDE68A' }; + return { level: 'Low', color: '#16A366', bg: '#EAFAF2', border: '#D1FAE5' }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// MAIN ENRICHMENT ORCHESTRATOR +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * @param {object} targetCase โ€“ Case record from localStorage / demo data + * @param {object[]} allCases โ€“ All known cases (for duplicate/trend checks) + * @param {function} onStep โ€“ Callback({ id, label, status:'running'|'done'|'error', data? }) + * @returns {Promise} + */ +async function runEnrichment(targetCase, allCases, onStep = () => {}) { + const report = targetCase.report || targetCase; + const loc = report.location || {}; + + const step = (id, label, status, data = null) => onStep({ id, label, status, data }); + + // โ”€โ”€ Factor 1 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + step('prior', 'Checking prior notice & 311 reportsโ€ฆ', 'running'); + const f1 = scorePriorNotice(targetCase, allCases); + await tick(300); + step('prior', f1.detail, 'done', f1); + + // โ”€โ”€ Factor 2 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + step('traffic', 'Classifying road type & traffic volumeโ€ฆ', 'running'); + const f2 = scoreTrafficVolume(report.address); + await tick(250); + step('traffic', f2.detail, 'done', f2); + + // โ”€โ”€ Factor 3 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + step('damage', 'Scoring physical damage from AI classificationโ€ฆ', 'running'); + const f3 = scoreDamageType(report.aiResult || report.aiPrediction); + await tick(200); + step('damage', f3.detail, 'done', f3); + + // โ”€โ”€ Factor 4 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + step('trend', 'Analysing recurrence & weekly comparisonโ€ฆ', 'running'); + const f4 = { score: 0, max: 0, detail: 'Deferred for later rollout', deferred: true }; + step('trend', f4.detail, 'done', f4); + + // โ”€โ”€ Factor 5 (Weather API) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + step('weather', 'Fetching 48h OpenWeatherMap forecastโ€ฆ', 'running'); + const f5 = await scoreWeather(loc.lat, loc.lng); + step('weather', f5.detail, f5.error ? 'warn' : 'done', f5); + + // โ”€โ”€ Duplicate check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + step('duplicate', `Scanning GPS radius (${ENRICH.DUP_RADIUS_M} m) for duplicatesโ€ฆ`, 'running'); + await tick(500); + const dup = checkDuplicates(targetCase, allCases); + step('duplicate', + dup.isDuplicate + ? `โš  ${dup.duplicateCount} duplicate(s) found โ€” severity boosted +${dup.effectiveSeverityBoost} pts` + : `No duplicates found within ${ENRICH.DUP_RADIUS_M} m โ€” proceeding as new case`, + 'done', dup); + + // โ”€โ”€ District mapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + step('district', 'Mapping ward / district / maintenance zoneโ€ฆ', 'running'); + const district = mapDistrict(report.ward, report.address); + await tick(300); + step('district', `Mapped โ†’ ${district.district} ยท ${district.maintenanceZone}`, 'done', district); + + // โ”€โ”€ Final score โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const base = f1.score + f2.score + f3.score; + const withWx = Math.min(100, base + f5.score); + const final = Math.min(100, withWx + dup.effectiveSeverityBoost); + const level = severityLevel(final); + + const result = { + enrichedAt : new Date().toISOString(), + enrichedBy : 'ops-console-v2', + severityScore : final, + severityLevel : level.level, + severityColor : level.color, + severityBg : level.bg, + severityBreakdown: { + priorNotice : f1, + traffic : f2, + damage : f3, + weather : f5, + }, + duplicate : dup, + district, + weather : f5.data, + }; + + step('complete', `Enrichment complete โ€” ${level.level} severity (${final}/100)`, 'done', result); + return result; +} + +function tick(ms) { return new Promise(r => setTimeout(r, ms)); } + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Browser global +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function mapDistrict(wardOrReport, address) { + const report = (wardOrReport && typeof wardOrReport === 'object') + ? wardOrReport + : ((typeof window !== 'undefined' && window.__currentEnrichmentReport) || { ward: wardOrReport, address }); + const gis = report?.gisMapping || {}; + // The Philadelphia GIS layer only has WARD polygons (no district/zone fields), + // so derive a Streets district + maintenance zone from the ward number. + const wardStr = gis.ward || report?.ward || gis.area || report?.areaLabel || ''; + const wn = parseInt((String(wardStr).match(/\d+/) || [])[0] || '0', 10); + const derivedDistrict = wn ? `Streets District ${((wn - 1) % 6) + 1}` : ''; // 6 highway districts + const derivedZone = wn ? `Zone-${wn}` : ''; + return { + district: gis.district || report?.district || derivedDistrict || 'Unassigned', + ward: gis.ward || report?.ward || gis.area || report?.areaLabel || 'Area detected', + maintenanceZone: gis.zone || report?.maintenanceZone || derivedZone || 'Unassigned', + gisSource: gis.source || (wn ? 'derived-from-ward' : 'pending'), + }; +} + +const __baseRunEnrichment = runEnrichment; +runEnrichment = async function runEnrichmentWithGIS(targetCase, allCases, onStep = () => {}) { + if (typeof window !== 'undefined') { + window.__currentEnrichmentReport = targetCase?.report || targetCase || null; + } + + try { + return await __baseRunEnrichment(targetCase, allCases, onStep); + } finally { + if (typeof window !== 'undefined') { + window.__currentEnrichmentReport = null; + } + } +}; + +if (typeof window !== 'undefined') { + window.Enrichment = { runEnrichment, severityLevel, haversine, checkDuplicates }; +} diff --git a/gis.js b/gis.js new file mode 100644 index 0000000000000000000000000000000000000000..fdc24fe546839ce889ccfcd2f2ce99ee49e263d0 --- /dev/null +++ b/gis.js @@ -0,0 +1,439 @@ +'use strict'; + +const GIS_STORAGE_KEYS = { + geojsonUrl: 'gis_geojson_url', + wardProperty: 'gis_ward_prop', + districtProperty: 'gis_district_prop', + zoneProperty: 'gis_zone_prop', +}; + +const DEFAULT_GIS_GEOJSON_URL = '/api/gis/boundaries'; +const GIS_FIELD_CANDIDATES = { + ward: ['ward', 'ward_num', 'ward_number', 'wardname', 'ward_name', 'name', 'label'], + district: ['district', 'district_name', 'council_district', 'maintenance_district'], + zone: ['zone', 'maintenance_zone', 'service_zone', 'route_zone'], +}; + +const __gisGeoJsonCache = new Map(); +const PHILLY_BOUNDS = { + minLat: 39.86, + maxLat: 40.14, + minLng: -75.28, + maxLng: -74.96, +}; +const PHILLY_TEN_REGIONS = [ + { name: 'Northeast', bounds: [[40.04, -75.07], [40.14, -74.96]], match: (lat, lng) => lat > 40.04 && lng > -75.07 }, + { name: 'Northwest', bounds: [[40.02, -75.28], [40.14, -75.17]], match: (lat, lng) => lat > 40.02 && lng < -75.17 }, + { name: 'North', bounds: [[40.02, -75.17], [40.14, -75.07]], match: (lat, lng) => lat > 40.02 && lng >= -75.17 && lng <= -75.07 }, + { name: 'Lower North', bounds: [[39.97, -75.20], [40.02, -75.14]], match: (lat, lng) => lat >= 39.97 && lat <= 40.02 && lng >= -75.20 && lng <= -75.14 }, + { name: 'West', bounds: [[39.94, -75.28], [40.04, -75.18]], match: (lat, lng) => lat >= 39.94 && lat <= 40.04 && lng < -75.18 }, + { name: 'River Wards', bounds: [[39.95, -75.09], [40.02, -74.96]], match: (lat, lng) => lat >= 39.95 && lat <= 40.02 && lng > -75.09 }, + { name: 'Central', bounds: [[39.94, -75.18], [40.02, -75.09]], match: (lat, lng) => lat >= 39.94 && lat <= 40.02 && lng >= -75.18 && lng <= -75.09 }, + { name: 'South', bounds: [[39.90, -75.20], [39.95, -75.10]], match: (lat, lng) => lat >= 39.90 && lat <= 39.95 && lng >= -75.20 && lng <= -75.10 }, + { name: 'Southwest', bounds: [[39.86, -75.28], [39.94, -75.15]], match: (lat, lng) => lat < 39.94 && lng < -75.15 }, + { name: 'East', bounds: [[39.86, -75.10], [39.95, -74.96]], match: (lat, lng) => lat < 39.95 && lng > -75.10 }, +]; + +function getGISConfig() { + const storedUrl = (localStorage.getItem(GIS_STORAGE_KEYS.geojsonUrl) || '').trim(); + return { + geojsonUrl: storedUrl || DEFAULT_GIS_GEOJSON_URL, + wardProperty: (localStorage.getItem(GIS_STORAGE_KEYS.wardProperty) || 'ward').trim(), + districtProperty: (localStorage.getItem(GIS_STORAGE_KEYS.districtProperty) || 'district').trim(), + zoneProperty: (localStorage.getItem(GIS_STORAGE_KEYS.zoneProperty) || 'zone').trim(), + }; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchWithRetry(url, options = {}, retryOptions = {}) { + const { + retries = 2, + baseDelayMs = 500, + retryStatuses = [408, 429, 500, 502, 503, 504], + label = 'GIS request', + } = retryOptions; + + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(url, options); + if (!retryStatuses.includes(response.status) || attempt === retries) { + return response; + } + + const retryAfter = response.headers.get('retry-after'); + const retryDelayMs = retryAfter + ? Number(retryAfter) * 1000 || baseDelayMs * (2 ** attempt) + : baseDelayMs * (2 ** attempt); + console.warn(`[gis.js] ${label} returned ${response.status}; retrying in ${retryDelayMs}ms`); + await sleep(retryDelayMs); + } catch (error) { + lastError = error; + if (attempt === retries) throw error; + const retryDelayMs = baseDelayMs * (2 ** attempt); + console.warn(`[gis.js] ${label} failed (${error.message}); retrying in ${retryDelayMs}ms`); + await sleep(retryDelayMs); + } + } + + throw lastError || new Error(`${label} failed.`); +} + +function getGISProperty(properties, key, fallbacks = []) { + if (!properties || !key) return ''; + if (Object.prototype.hasOwnProperty.call(properties, key)) { + return properties[key]; + } + + const loweredKey = String(key).toLowerCase(); + const match = Object.keys(properties).find((candidate) => candidate.toLowerCase() === loweredKey); + if (match) return properties[match]; + + for (const fallback of fallbacks) { + if (!fallback) continue; + if (Object.prototype.hasOwnProperty.call(properties, fallback)) { + return properties[fallback]; + } + const loweredFallback = String(fallback).toLowerCase(); + const fallbackMatch = Object.keys(properties).find((candidate) => candidate.toLowerCase() === loweredFallback); + if (fallbackMatch) return properties[fallbackMatch]; + } + + return ''; +} + +function getAreaLabelFromAddress(address) { + if (!address || typeof address !== 'object') return ''; + return ( + address.suburb || + address.neighbourhood || + address.city_district || + address.quarter || + address.hamlet || + address.village || + address.town || + address.city || + address.county || + '' + ); +} + +function pointInRing(point, ring) { + const [x, y] = point; + let inside = false; + + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + const intersects = ((yi > y) !== (yj > y)) && + (x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi); + + if (intersects) inside = !inside; + } + + return inside; +} + +function pointInPolygon(point, polygonCoords) { + if (!Array.isArray(polygonCoords) || !polygonCoords.length) return false; + if (!pointInRing(point, polygonCoords[0])) return false; + + for (let i = 1; i < polygonCoords.length; i += 1) { + if (pointInRing(point, polygonCoords[i])) return false; + } + + return true; +} + +function pointInGeometry(point, geometry) { + if (!geometry || !geometry.type || !geometry.coordinates) return false; + + if (geometry.type === 'Polygon') { + return pointInPolygon(point, geometry.coordinates); + } + + if (geometry.type === 'MultiPolygon') { + return geometry.coordinates.some((polygon) => pointInPolygon(point, polygon)); + } + + return false; +} + +function extendBounds(bounds, lng, lat) { + bounds.minLat = Math.min(bounds.minLat, lat); + bounds.maxLat = Math.max(bounds.maxLat, lat); + bounds.minLng = Math.min(bounds.minLng, lng); + bounds.maxLng = Math.max(bounds.maxLng, lng); +} + +function geometryToBounds(geometry) { + if (!geometry || !geometry.type || !geometry.coordinates) return null; + + const bounds = { + minLat: Infinity, + maxLat: -Infinity, + minLng: Infinity, + maxLng: -Infinity, + }; + + const visitRing = (ring) => { + ring.forEach(([lng, lat]) => extendBounds(bounds, lng, lat)); + }; + + if (geometry.type === 'Polygon') { + geometry.coordinates.forEach(visitRing); + } else if (geometry.type === 'MultiPolygon') { + geometry.coordinates.forEach((polygon) => polygon.forEach(visitRing)); + } else { + return null; + } + + if (!Number.isFinite(bounds.minLat) || !Number.isFinite(bounds.minLng)) return null; + return [[bounds.minLat, bounds.minLng], [bounds.maxLat, bounds.maxLng]]; +} + +function createGISDisplayLabel(mapping) { + if (!mapping) return ''; + + const parts = [mapping.ward, mapping.district, mapping.zone].filter(Boolean); + if (parts.length) return parts.join(' / '); + return mapping.area || ''; +} + +function normalizeGISMapping(mapping, fallbackArea = '') { + const base = (mapping && typeof mapping === 'object') ? mapping : {}; + const normalized = { + configured: !!base.configured, + matched: !!base.matched, + ward: base.ward ? String(base.ward) : '', + district: base.district ? String(base.district) : '', + zone: base.zone ? String(base.zone) : (base.maintenanceZone ? String(base.maintenanceZone) : ''), + area: base.area ? String(base.area) : String(fallbackArea || ''), + source: base.source || 'none', + bounds: Array.isArray(base.bounds) ? base.bounds : null, + featureProperties: base.featureProperties || null, + message: base.message || '', + }; + + normalized.label = createGISDisplayLabel(normalized); + return normalized; +} + +function isInPhiladelphiaBounds(lat, lng) { + return lat >= PHILLY_BOUNDS.minLat && + lat <= PHILLY_BOUNDS.maxLat && + lng >= PHILLY_BOUNDS.minLng && + lng <= PHILLY_BOUNDS.maxLng; +} + +function resolvePhiladelphiaTenRegion(lat, lng) { + if (!isInPhiladelphiaBounds(lat, lng)) return null; + + const region = PHILLY_TEN_REGIONS.find((candidate) => candidate.match(lat, lng)); + if (!region) return null; + + return normalizeGISMapping({ + configured: true, + matched: true, + ward: region.name, + district: '', + zone: '', + area: region.name, + source: 'philly-ten-region', + bounds: region.bounds, + message: 'Matched against the built-in Philadelphia 10-region fallback.', + }, region.name); +} + +function isPhiladelphiaTenRegionName(name) { + return !!PHILLY_TEN_REGIONS.find((region) => region.name === name); +} + +function resolvePreferredGISMapping(lat, lng, rawMapping, fallbackArea = '') { + const normalized = normalizeGISMapping(rawMapping, fallbackArea); + const phillyRegion = resolvePhiladelphiaTenRegion(lat, lng); + + if (!phillyRegion) return normalized; + + const wardLabel = normalized.ward || ''; + const districtLabel = normalized.district || ''; + const looksLikeLegacyPhillyCode = + /^ward\s*\d+/i.test(wardLabel) || + /^district[-\s]/i.test(districtLabel) || + /^ward[-\s]/i.test(wardLabel); + const isAlreadyTenRegion = isPhiladelphiaTenRegionName(wardLabel); + + if (!normalized.label || looksLikeLegacyPhillyCode || !isAlreadyTenRegion) { + return phillyRegion; + } + + return normalized; +} + +async function fetchGISFeatureCollection(config = getGISConfig()) { + const { geojsonUrl } = config; + if (!geojsonUrl) return null; + + if (!__gisGeoJsonCache.has(geojsonUrl)) { + const requestPromise = fetchWithRetry(geojsonUrl, {}, { + label: 'GIS boundary fetch', + }).then(async (response) => { + if (!response.ok) { + throw new Error(`GIS boundary fetch failed (${response.status})`); + } + + const data = await response.json(); + if (!data || !Array.isArray(data.features)) { + throw new Error('GIS boundary data must be a GeoJSON FeatureCollection.'); + } + + return data; + }).catch((error) => { + __gisGeoJsonCache.delete(geojsonUrl); + throw error; + }); + + __gisGeoJsonCache.set(geojsonUrl, requestPromise); + } + + return __gisGeoJsonCache.get(geojsonUrl); +} + +async function resolveGISMapping(lat, lng, options = {}) { + const fallbackArea = options.fallbackArea || ''; + const config = options.config || getGISConfig(); + + if (!config.geojsonUrl) { + const phillyRegion = resolvePhiladelphiaTenRegion(lat, lng); + if (phillyRegion) return phillyRegion; + + return normalizeGISMapping({ + configured: false, + matched: false, + area: fallbackArea, + source: fallbackArea ? 'reverse-geocode' : 'none', + message: 'Official GIS ward boundaries are not configured.', + }, fallbackArea); + } + + try { + const featureCollection = await fetchGISFeatureCollection(config); + const point = [lng, lat]; + + for (const feature of featureCollection.features) { + if (!pointInGeometry(point, feature.geometry)) continue; + + const properties = feature.properties || {}; + const wardValue = getGISProperty(properties, config.wardProperty, GIS_FIELD_CANDIDATES.ward); + const districtValue = getGISProperty(properties, config.districtProperty, GIS_FIELD_CANDIDATES.district); + const zoneValue = getGISProperty(properties, config.zoneProperty, GIS_FIELD_CANDIDATES.zone); + return normalizeGISMapping({ + configured: true, + matched: true, + ward: wardValue, + district: districtValue, + zone: zoneValue, + area: fallbackArea || wardValue || districtValue || zoneValue, + source: config.geojsonUrl === DEFAULT_GIS_GEOJSON_URL ? 'official-geojson' : 'geojson', + bounds: geometryToBounds(feature.geometry), + featureProperties: properties, + message: config.geojsonUrl === DEFAULT_GIS_GEOJSON_URL + ? 'Matched against the official GIS boundary layer.' + : 'Matched against configured GIS ward boundaries.', + }, fallbackArea); + } + + const phillyRegion = resolvePhiladelphiaTenRegion(lat, lng); + if (phillyRegion) return phillyRegion; + + return normalizeGISMapping({ + configured: true, + matched: false, + area: fallbackArea, + source: fallbackArea ? 'reverse-geocode' : 'none', + message: 'Coordinates did not match any feature in the configured GIS boundary layer.', + }, fallbackArea); + } catch (error) { + console.warn('[gis.js] resolveGISMapping failed:', error); + return normalizeGISMapping({ + configured: true, + matched: false, + area: fallbackArea, + source: fallbackArea ? 'reverse-geocode' : 'none', + message: error.message || 'GIS lookup failed.', + }, fallbackArea); + } +} + +function getGISBadgeTone(mapping) { + if (mapping && mapping.matched) { + return { + background: '#EFF6FF', + color: '#1D4ED8', + border: '#BFDBFE', + dot: '#2563EB', + }; + } + + if (mapping && mapping.area) { + return { + background: '#ECFDF5', + color: '#0F766E', + border: '#A7F3D0', + dot: '#0D9488', + }; + } + + return { + background: '#F8FAFC', + color: '#64748B', + border: '#CBD5E1', + dot: '#94A3B8', + }; +} + +function getGISStatusText(mapping) { + if (!mapping) return 'GIS mapping pending'; + if (mapping.matched) return `GIS mapped: ${mapping.label}`; + if (mapping.area) return `Area detected: ${mapping.area}`; + return 'GIS mapping pending'; +} + +if (typeof window !== 'undefined') { + window.DEFAULT_GIS_GEOJSON_URL = DEFAULT_GIS_GEOJSON_URL; + window.GIS_STORAGE_KEYS = GIS_STORAGE_KEYS; + window.getGISConfig = getGISConfig; + window.getAreaLabelFromAddress = getAreaLabelFromAddress; + window.fetchGISFeatureCollection = fetchGISFeatureCollection; + window.resolveGISMapping = resolveGISMapping; + window.resolvePhiladelphiaTenRegion = resolvePhiladelphiaTenRegion; + window.resolvePreferredGISMapping = resolvePreferredGISMapping; + window.PHILLY_TEN_REGIONS = PHILLY_TEN_REGIONS; + window.normalizeGISMapping = normalizeGISMapping; + window.createGISDisplayLabel = createGISDisplayLabel; + window.getGISBadgeTone = getGISBadgeTone; + window.getGISStatusText = getGISStatusText; + window.geometryToBounds = geometryToBounds; +} + +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = { + DEFAULT_GIS_GEOJSON_URL, + GIS_STORAGE_KEYS, + getGISConfig, + getAreaLabelFromAddress, + fetchGISFeatureCollection, + resolveGISMapping, + resolvePhiladelphiaTenRegion, + resolvePreferredGISMapping, + PHILLY_TEN_REGIONS, + normalizeGISMapping, + createGISDisplayLabel, + getGISBadgeTone, + getGISStatusText, + geometryToBounds, + }; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..040eb7601fc3c1af45237fc33afbb3380f7281ab --- /dev/null +++ b/index.html @@ -0,0 +1,2628 @@ + + + + + + Report a Pothole - City Road Reporting Portal + + + + + + + + + + + + +
+ +
+ + +
+
+

Report a Road Pothole

+

Upload a photo, verify location, and submit. The portal records live coordinates and uses official GIS routing when a boundary layer is configured.

+
+
+ + +
+ + +
+
+
1
+ Upload Photo +
+
+
+
2
+ Verify Location +
+
+
+
3
+ Submit Report +
+
+ + +
+ + +
+
+
Step 1 of 3
+

Upload a Photo

+

Take or upload a clear photo of the pothole. GPS will be extracted automatically from the image.

+
+ + +
+
+
+ + + + + +
+
Click or drag a photo here
+
Supports JPG, PNG, HEIC ยท Max 50MB
+ + +
+ +
+ + + + + + + + +
+ + + + + + + +
+ + + + +
+ + +
+
+
How It Works
+

From Photo to Repair โ€” Automated

+
+
+
+ + + + + +
+
01
+
Upload Photo
+
Take or upload a photo of the pothole. AI immediately verifies it's a real pothole and classifies severity.
+
+
+
+ + + + +
+
02
+
GPS Verified
+
EXIF data is extracted from the photo and cross-checked with your device location for pinpoint accuracy.
+
+
+
+ + + +
+
03
+
AI Routing
+
Box AI scores severity, checks for duplicates, and adds GIS routing when official boundary data is available.
+
+
+
+ + + + + +
+
04
+
Engineer Review
+
District engineer approves and dispatches a repair crew. Track the status of your case anytime.
+
+
+
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/job_sheet_template.html b/job_sheet_template.html new file mode 100644 index 0000000000000000000000000000000000000000..9fc643fd1d9baeb64a62220de7b640d3097598db --- /dev/null +++ b/job_sheet_template.html @@ -0,0 +1,186 @@ + + + + + Crew Dispatch Job Sheet + + + + + + + + + +
+
Crew Dispatch Job Sheet
+
Roadway Maintenance & Triage Operations
+
+
Job Form: DPW-JS-V2
+
Status: ASSIGNED
+
+ +
1. Job Details (Auto-populated by Box)
+
+
Case ID: ___________________________
+
Assigned Ward: ___________________________
+
Repair Address: ___________________________
+
Assigned Zone: ___________________________
+
Severity Level: ___________________________
+
Target Completion: ___________________________
+
+ +
2. Required On-Site Safety Checks
+ + + + + + + + + + + + + + + + + + + + + +
DoneSafety Checkpoint / Guardrail Requirement
[ ]Traffic Controls: Cones placed, sign boards active, lane closure secured if arterial road.
[ ]PPE Checked: High-visibility vests, hard hats, steel-toe boots, gloves active on site.
[ ]Utility Check: Verify no active sub-surface utilities damaged or exposed.
+ +
3. Closeout Logs (To be completed by Crew Lead)
+ +
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+ +
+
+ +
4. Field Verification & Signatures
+
+
+
+
Crew Lead Signature
+
Sign on completion of roadway repairs
+
+
+
+
Date Signed
+
Date work completed in field
+
+
+ + + diff --git a/location.js b/location.js new file mode 100644 index 0000000000000000000000000000000000000000..63c9a2f8483969803022041b328075e50a726dd4 --- /dev/null +++ b/location.js @@ -0,0 +1,532 @@ +/** + * location.js + * ----------- + * Handles all GPS and location logic for the Citizen Pothole Portal. + * + * Depends on: + * - exifr (loaded via CDN ). + * + * @param {File} file - A File object (from or drag-and-drop). + * @returns {Promise<{lat: number, lng: number, source: 'exif'} | null>} + * Resolves with a GPS object if coordinates are found, or null otherwise. + */ +async function extractExifGPS(file) { + try { + // Ensure the exifr library is available on the global scope + if (typeof exifr === 'undefined') { + console.warn('[location.js] exifr is not loaded. Cannot extract EXIF GPS.'); + return null; + } + + // Parse only the GPS tags for efficiency + const gps = await exifr.gps(file); + + // exifr returns null / undefined when no GPS data is present + if (!gps || gps.latitude == null || gps.longitude == null) { + console.info('[location.js] No EXIF GPS data found in image.'); + return null; + } + + return { + lat: gps.latitude, + lng: gps.longitude, + source: 'exif', + }; + } catch (err) { + console.error('[location.js] extractExifGPS error:', err); + return null; + } +} + +/* ========================================================================= + 2. DEVICE / BROWSER GEOLOCATION + ========================================================================= */ + +/** + * getDeviceLocation + * ----------------- + * Requests the user's current position via the browser Geolocation API. + * The user will be prompted for permission if it has not been granted yet. + * + * @returns {Promise<{lat: number, lng: number, source: 'device', accuracy: number}>} + * Resolves with the device position, or rejects with an Error if + * permission is denied or the position cannot be determined. + */ +function getDeviceLocation() { + return new Promise((resolve, reject) => { + // Check that the Geolocation API is supported + if (!navigator.geolocation) { + reject(new Error('Geolocation is not supported by this browser.')); + return; + } + + const options = { + enableHighAccuracy: true, // Request the most accurate position available + timeout: 10000, // Wait up to 10 seconds + maximumAge: 30000, // Accept a cached position up to 30 seconds old + }; + + navigator.geolocation.getCurrentPosition( + (position) => { + resolve({ + lat: position.coords.latitude, + lng: position.coords.longitude, + source: 'device', + accuracy: position.coords.accuracy, // metres + }); + }, + (error) => { + // Map GeolocationPositionError codes to human-readable messages + const messages = { + 1: 'Location permission denied by the user.', + 2: 'Position unavailable โ€“ the device could not determine its location.', + 3: 'Geolocation request timed out.', + }; + reject(new Error(messages[error.code] || 'Unknown geolocation error.')); + }, + options + ); + }); +} + +/* ========================================================================= + 3. REVERSE GEOCODING (Nominatim / OpenStreetMap) + ========================================================================= */ + +/** + * reverseGeocode + * -------------- + * Converts a latitude / longitude pair into a human-readable address using + * the free Nominatim API (https://nominatim.openstreetmap.org). + * + * No API key is required, but Nominatim's usage policy requires: + * โ€ข A valid User-Agent header (set below). + * โ€ข A maximum of 1 request per second. + * + * @param {number} lat - Latitude. + * @param {number} lng - Longitude. + * @returns {Promise<{address: string, city: string, state: string, zip: string}>} + * Resolves with structured address components. + */ +async function reverseGeocodeDetailed(lat, lng) { + const googleMapsApiKey = getGoogleMapsJsApiKey(); + if (googleMapsApiKey) { + try { + return await reverseGeocodeViaGoogle(lat, lng); + } catch (err) { + console.warn('[location.js] Google reverse geocode failed, falling back to Nominatim:', err); + } + } + + const url = + `https://nominatim.openstreetmap.org/reverse` + + `?format=jsonv2&lat=${lat}&lon=${lng}&addressdetails=1`; + + try { + const response = await fetch(url, { + headers: { + // Nominatim requires a descriptive User-Agent + 'User-Agent': 'CitizenPotholePortal/1.0 (philadelphia-potholes@example.com)', + 'Accept-Language': 'en', + }, + }); + + if (!response.ok) { + throw new Error(`Nominatim HTTP error: ${response.status}`); + } + + const data = await response.json(); + const addr = data.address || {}; + + // Build a single-line address string from the most relevant OSM fields + const streetNumber = addr.house_number || ''; + const street = addr.road || addr.pedestrian || addr.footway || ''; + const fullStreet = [streetNumber, street].filter(Boolean).join(' '); + + return { + // Human-readable single line: "1234 Market St, Philadelphia, PA 19103" + address : data.display_name || fullStreet || 'Address unavailable', + city : addr.city || addr.town || addr.village || addr.county || '', + state : addr.state || '', + zip : addr.postcode || '', + areaLabel: addr.suburb || addr.neighbourhood || addr.city_district || addr.quarter || addr.city || addr.county || '', + provider: 'nominatim', + }; + } catch (err) { + console.error('[location.js] reverseGeocode error:', err); + // Return graceful fallback rather than throwing + return { + address : 'Geocoding unavailable', + city : '', + state : '', + zip : '', + areaLabel: '', + provider: 'unavailable', + }; + } +} + +async function reverseGeocode(lat, lng) { + const result = await reverseGeocodeDetailed(lat, lng); + return { + address: result.address, + city: result.city, + state: result.state, + zip: result.zip, + }; +} + +/* ========================================================================= + 4. LOCATION CROSS-VALIDATION + ========================================================================= */ + +/** + * haversineDistance + * ----------------- + * Calculates the great-circle distance between two GPS coordinates using the + * Haversine formula. + * + * @param {number} lat1 - Latitude of point 1 (degrees). + * @param {number} lng1 - Longitude of point 1 (degrees). + * @param {number} lat2 - Latitude of point 2 (degrees). + * @param {number} lng2 - Longitude of point 2 (degrees). + * @returns {number} Distance in metres. + */ +function haversineDistance(lat1, lng1, lat2, lng2) { + const R = 6371000; // Earth's mean radius in metres + + // Convert degrees to radians + const toRad = (deg) => (deg * Math.PI) / 180; + + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * + Math.cos(toRad(lat2)) * + Math.sin(dLng / 2) * + Math.sin(dLng / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; // metres +} + +/** + * validateLocations + * ----------------- + * Cross-validates two GPS positions (e.g. one from EXIF, one from the device) + * and returns a verdict on whether they agree. + * + * Threshold used: positions within 100 m are considered a match. + * + * @param {{ lat: number, lng: number } | null} exifPos - GPS from image EXIF (may be null). + * @param {{ lat: number, lng: number } | null} devicePos - GPS from device (may be null). + * @returns {{ + * match: boolean, + * distanceMeters: number | null, + * status: 'verified' | 'mismatch' | 'single' + * }} + */ +function validateLocations(exifPos, devicePos) { + // If only one source is available we cannot cross-validate + if (!exifPos || !devicePos) { + return { + match: false, + distanceMeters: null, + status: 'single', // Only one data source; treated as unverified but acceptable + }; + } + + const distanceMeters = haversineDistance( + exifPos.lat, exifPos.lng, + devicePos.lat, devicePos.lng + ); + + // Positions within 100 metres are considered consistent / verified + const MATCH_THRESHOLD_METERS = 100; + const isMatch = distanceMeters <= MATCH_THRESHOLD_METERS; + + return { + match: isMatch, + distanceMeters: Math.round(distanceMeters), // whole metres for display + status: isMatch ? 'verified' : 'mismatch', + }; +} + +/* ========================================================================= + 5. PHILADELPHIA WARD / PLANNING-DISTRICT DETECTION + ========================================================================= */ + +/** + * PHILLY_DISTRICTS + * ---------------- + * Hardcoded bounding-box definitions for Philadelphia's 10 planning districts. + * Each entry is evaluated in order; the first matching region wins. + * + * Approximate lat/lng boundaries based on the City Planning Commission's + * published district boundaries. + * + * Philadelphia overall bounding box: lat 39.86โ€“40.14, lng -75.28 to -74.96 + */ +const PHILLY_DISTRICTS = [ + { + ward : 'Northeast', + wardId : 'WARD-NE', + district: 'District-06-Northeast', + // North of City Ave corridor, east of Roosevelt Blvd + match : (lat, lng) => lat > 40.04 && lng > -75.07, + }, + { + ward : 'Northwest', + wardId : 'WARD-NW', + district: 'District-09-Northwest', + // North of City Ave corridor, west of Broad Street area + match : (lat, lng) => lat > 40.02 && lng < -75.17, + }, + { + ward : 'North', + wardId : 'WARD-NO', + district: 'District-07-North', + // Upper North Philly corridor between Roosevelt and Broad + match : (lat, lng) => lat > 40.02 && lng >= -75.17 && lng <= -75.07, + }, + { + ward : 'Lower North', + wardId : 'WARD-LN', + district: 'District-08-Lower-North', + // Below North, above Center City, west-central + match : (lat, lng) => + lat >= 39.97 && lat <= 40.02 && lng >= -75.20 && lng <= -75.14, + }, + { + ward : 'West', + wardId : 'WARD-WE', + district: 'District-04-West', + // West of the Schuylkill (west of roughly -75.18) + match : (lat, lng) => lat >= 39.94 && lat <= 40.04 && lng < -75.18, + }, + { + ward : 'River Wards', + wardId : 'WARD-RW', + district: 'District-01-River-Wards', + // Fishtown / Kensington / Port Richmond corridor along the Delaware + match : (lat, lng) => lat >= 39.95 && lat <= 40.02 && lng > -75.09, + }, + { + ward : 'Central', + wardId : 'WARD-CE', + district: 'District-05-Central', + // Center City and surrounding neighbourhoods + match : (lat, lng) => + lat >= 39.94 && lat <= 40.02 && lng >= -75.18 && lng <= -75.09, + }, + { + ward : 'South', + wardId : 'WARD-SO', + district: 'District-02-South', + // South Philadelphia proper + match : (lat, lng) => + lat >= 39.90 && lat <= 39.95 && lng >= -75.20 && lng <= -75.10, + }, + { + ward : 'Southwest', + wardId : 'WARD-SW', + district: 'District-03-Southwest', + // Southwest Philly / Eastwick + match : (lat, lng) => lat < 39.94 && lng < -75.15, + }, + { + ward : 'East', + wardId : 'WARD-EA', + district: 'District-10-East', + // Lower Northeast / far-east corridors along the Delaware below Fishtown + match : (lat, lng) => lat < 39.95 && lng > -75.10, + }, +]; + +/** Fallback used when coordinates fall outside every defined district. */ +const UNKNOWN_DISTRICT = { + ward : 'Unknown', + wardId : 'WARD-XX', + district: 'District-00-Unknown', +}; + +/** + * detectWard + * ---------- + * Determines which Philadelphia planning district a set of GPS coordinates + * falls within using simple bounding-box logic. + * + * @param {number} lat - Latitude. + * @param {number} lng - Longitude. + * @returns {{ + * ward : string, + * wardId : string, + * district : string + * }} + * + * @example + * detectWard(39.9526, -75.1652); + * // => { ward: 'Central', wardId: 'WARD-CE', district: 'District-05-Central' } + */ +function detectWard(lat, lng) { + // Sanity-check: confirm coordinates are within Philadelphia's bounding box + if (lat < 39.86 || lat > 40.14 || lng < -75.28 || lng > -74.96) { + console.warn( + `[location.js] detectWard: coordinates (${lat}, ${lng}) are outside Philadelphia.` + ); + return { ...UNKNOWN_DISTRICT }; + } + + // Evaluate each district's bounding-box predicate in priority order + for (const district of PHILLY_DISTRICTS) { + if (district.match(lat, lng)) { + return { + ward : district.ward, + wardId : district.wardId, + district: district.district, + }; + } + } + + // Coordinates are within Philadelphia but didn't match any polygon + console.warn( + `[location.js] detectWard: no district matched for (${lat}, ${lng}). Returning unknown.` + ); + return { ...UNKNOWN_DISTRICT }; +} + +/* ========================================================================= + 6. MODULE EXPORT (works in both browser globals and ES-module contexts) + ========================================================================= */ + +// Make functions available as plain globals in a classic browser + + + diff --git a/ops-review.html b/ops-review.html new file mode 100644 index 0000000000000000000000000000000000000000..6a33230d87c1cd40c949b64a32241ebe7625c909 --- /dev/null +++ b/ops-review.html @@ -0,0 +1,3597 @@ + + + + + + Ops Review โ€” Internal Console + + + + + + + + + + + + + +
+ + +
+
+
+
+ + Supervisor Portal +
+ +

+ Case Review
+ & Dispatch Portal +

+

+ Full-access dashboard for reviewing AI classifications, reporter data, GIS spatial routing, and supervisor sign-off. +

+ +
+
+
Access Level
+
Full โ€” Raw AI + PII
+
+
+
Security
+
Local Session Verified
+
+
+
Visibility
+
Hidden from public nav
+
+
+
Session
+
Browser-scoped
+
+
+ +
+
+
AI
+ Raw AI class probabilities & road-likeness scores +
+
+
SV
+ Supervisor sign-off & manual override tools +
+
+
GPS
+ Reporter PII, GPS source, ward classification +
+
+
GIS
+ GIS spatial routing & neighborhood sync +
+
+
+
+ + +
+
+
+ + + + +
+
+
City Road Reporting
+
Pothole Reporting Portal
+
+
+ +
Supervisor Sign In
+
Verify your identity below to access the supervisor review panel.
+ +
+ + +
+ + + + + +
+
+ + +
+ + + + + +
+ + +
+ + +
+
Operations Review Desk
+
Live Supervisor Session
+
Database-backed review workspace with Box serving as the document archive for case materials.
+
+ Database + Box Archive + GIS, AI, and Archive Online +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ Recent Cases + +
+
+
No submissions yet
+
+
+ +
+ + +
+ +
+
+
+
Supervisor Operations
+
Pothole Review Console
+
Validate submissions, inspect photo evidence, confirm GIS placement, and release verified cases to dispatch.
+
+
+
+
+
Cases In Queue
+
0
+
Recent cases available for review.
+
+
+
Needs Attention
+
0
+
Open cases not yet resolved or rejected.
+
+
+
Priority Queue
+
0
+
High-severity issues requiring faster dispatch.
+
+
+
+ + + + + +
+
+ +
+
Select a case
+
Click a case in the sidebar or search by ID to view full details, AI outputs, and supervisor tools.
+
+ + + + + + + +
+
+
+ +
+ + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..fa48fdb925ce2817c85ef62ba04877a9714e7317 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,272 @@ +{ + "name": "pothole-box", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pothole-box", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@supabase/supabase-js": "^2.108.2", + "jose": "^6.2.3", + "pg": "^8.22.0" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz", + "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz", + "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz", + "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz", + "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", + "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz", + "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.108.2", + "@supabase/functions-js": "2.108.2", + "@supabase/postgrest-js": "2.108.2", + "@supabase/realtime-js": "2.108.2", + "@supabase/storage-js": "2.108.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..b0bf64dedf1f53b305a928773ee12fddaa563d50 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "pothole-box", + "version": "1.0.0", + "description": "", + "main": "backend-api.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@supabase/supabase-js": "^2.108.2", + "jose": "^6.2.3", + "pg": "^8.22.0" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..bdd7cd7b1b8a2a48905f643a840405fe416c0f0f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Python deps for the AI classifier + document generation. +# torch/torchvision are installed separately in the Dockerfile from the CPU wheel index. +ultralytics +python-docx diff --git a/review.html b/review.html new file mode 100644 index 0000000000000000000000000000000000000000..debe898581278a5e934830ddc11b5cc2078bbec4 --- /dev/null +++ b/review.html @@ -0,0 +1,12 @@ + + + + + + Review Console + + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000000000000000000000000000000000000..184c227afb10cf4bd83615d8f4a4183e28ff1f82 --- /dev/null +++ b/server.js @@ -0,0 +1,2822 @@ +/** + * Lightweight backend for the pothole workflow app. + * + * Responsibilities: + * - Serve the existing static app + * - Persist the shared operational case state to disk + * - Accept case and workflow updates from the frontend + * - Store generated workflow artifacts in Box as a content repository + * - Optionally accept legacy Box Automate callbacks for compatibility + */ + +'use strict'; + +const http = require('http'); +const fs = require('fs/promises'); +const path = require('path'); +const os = require('os'); +const { execFile, spawn } = require('child_process'); +const { URL } = require('url'); +const { createConfigStore } = require('./config-store.js'); +const { createBoxService, stripPhotoDataUrl } = require('./box-service.js'); +const db = require('./db.js'); +const { createRemoteJWKSet, jwtVerify } = require('jose'); + +// โ”€โ”€ Supervisor auth (Supabase JWT verified via JWKS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const SUPABASE_AUTH_URL = (process.env.SUPABASE_URL || '').replace(/\/+$/, ''); +const SUPABASE_JWKS = SUPABASE_AUTH_URL + ? createRemoteJWKSet(new URL(`${SUPABASE_AUTH_URL}/auth/v1/.well-known/jwks.json`)) + : null; +// Supervisor login is OFF by default now. Re-enable by setting DASHBOARD_AUTH=true. +const AUTH_ENABLED = !!SUPABASE_JWKS && process.env.DASHBOARD_AUTH === 'true'; + +// Returns the JWT payload if the request carries a valid SUPERVISOR token, else null. +// Token may come from the Authorization: Bearer header OR an ?access_token= query +// param (so can authenticate too). +async function verifySupervisor(req, reqUrl) { + if (!SUPABASE_JWKS) return null; + let token = ''; + const h = String(req.headers['authorization'] || ''); + if (h.startsWith('Bearer ')) token = h.slice(7).trim(); + if (!token && reqUrl) token = (reqUrl.searchParams.get('access_token') || '').trim(); + if (!token) return null; + try { + const { payload } = await jwtVerify(token, SUPABASE_JWKS, { + issuer: `${SUPABASE_AUTH_URL}/auth/v1`, + audience: 'authenticated', + }); + return payload?.app_metadata?.role === 'supervisor' ? payload : null; + } catch (_) { + return null; + } +} + +const ROOT_DIR = __dirname; +// DATA_DIR: override for hosts with a persistent mount (e.g. HuggingFace Spaces +// persistent storage at /data โ€” set DATA_DIR=/data/potholeiq). +const DATA_DIR = process.env.DATA_DIR || path.join(ROOT_DIR, 'data'); +const STORE_PATH = path.join(DATA_DIR, 'cases.json'); +const AUTHORITY_ROUTING_PATH = path.join(ROOT_DIR, 'authority-routing.json'); +const GIS_BOUNDARIES_PATH = path.join(DATA_DIR, 'ward-boundaries.geojson'); +const DEMO_IMAGE_DIR_NAME = 'demo pothole images'; +const DEMO_IMAGE_DIR_PATH = path.join(ROOT_DIR, DEMO_IMAGE_DIR_NAME); + +const PORT = Number(process.env.PORT || 3030); +const HOST = process.env.HOST || '0.0.0.0'; +const BOX_AUTOMATE_SECRET = String(process.env.BOX_AUTOMATE_SECRET || '').trim(); +const MAX_BODY_SIZE = 2 * 1024 * 1024; +const MAX_UPLOAD_BODY_SIZE = 25 * 1024 * 1024; + +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', + '.map': 'application/json; charset=utf-8', +}; + +let storeWriteQueue = Promise.resolve(); +const configStore = createConfigStore({ dataDir: DATA_DIR }); +const boxService = createBoxService({ configStore }); + +function nowIso() { + return new Date().toISOString(); +} + +function withApiHeaders(extra = {}) { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Box-Automate-Secret', + ...extra, + }; +} + +function sendJson(res, statusCode, payload, extraHeaders = {}) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(statusCode, withApiHeaders({ + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + ...extraHeaders, + })); + res.end(body); +} + +function sendText(res, statusCode, body, extraHeaders = {}) { + res.writeHead(statusCode, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + ...extraHeaders, + }); + res.end(body); +} + +function sendBuffer(res, statusCode, body, extraHeaders = {}, includeApiHeaders = false) { + const headers = { + 'Content-Length': body.length, + ...extraHeaders, + }; + res.writeHead(statusCode, includeApiHeaders ? withApiHeaders(headers) : headers); + res.end(body); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function deepMerge(baseValue, incomingValue) { + if (!isPlainObject(baseValue) || !isPlainObject(incomingValue)) { + return incomingValue === undefined ? baseValue : incomingValue; + } + + const result = { ...baseValue }; + for (const [key, incoming] of Object.entries(incomingValue)) { + const existing = result[key]; + if (Array.isArray(incoming)) { + result[key] = incoming.slice(); + continue; + } + if (isPlainObject(existing) && isPlainObject(incoming)) { + result[key] = deepMerge(existing, incoming); + continue; + } + if (incoming !== undefined) { + result[key] = incoming; + } + } + return result; +} + +function toTimestamp(value) { + const parsed = Date.parse(String(value || '')); + return Number.isFinite(parsed) ? parsed : 0; +} + +function caseFreshness(record) { + if (!record || typeof record !== 'object') return 0; + const report = record.report || {}; + return Math.max( + toTimestamp(record.updatedAt), + toTimestamp(record.statusUpdatedAt), + toTimestamp(record.ts), + toTimestamp(record.timestamp), + toTimestamp(report.workflowLastUpdatedAt), + toTimestamp(report.resolvedAt), + toTimestamp(report.supervisor?.at), + toTimestamp(report.enrichment?.enrichedAt), + toTimestamp(report.submittedAt) + ); +} + +function sortCasesAscending(cases) { + return [...cases].sort((a, b) => { + const aTs = caseFreshness(a) || toTimestamp(a?.ts) || toTimestamp(a?.timestamp); + const bTs = caseFreshness(b) || toTimestamp(b?.ts) || toTimestamp(b?.timestamp); + if (aTs !== bTs) return aTs - bTs; + return String(a?.caseId || '').localeCompare(String(b?.caseId || '')); + }); +} + +function deriveCaseStatus(input = {}) { + const explicitStatus = String(input.status || '').trim(); + if (explicitStatus) return explicitStatus; + + const workflowStatus = String(input.workflowStatus || input.report?.workflowStatus || '').trim(); + if (workflowStatus) { + switch (workflowStatus.toLowerCase()) { + case 'awaiting approval': + case 'under review': + return 'Under Review'; + case 'assigned': + return 'Assigned'; + case 'in progress': + return 'In Progress'; + case 'resolved': + return 'Resolved'; + case 'rejected': + return 'Rejected'; + default: + return workflowStatus; + } + } + + const workflowStage = String(input.workflowStage || input.report?.workflowStage || '').trim().toLowerCase(); + switch (workflowStage) { + case 'supervisor_review': + case 'incoming_detection': + return 'Under Review'; + case 'crew_dispatch': + return 'Assigned'; + case 'repair_in_progress': + return 'In Progress'; + case 'resolved': + case 'ready_for_closeout': + return 'Resolved'; + case 'rejected': + return 'Rejected'; + default: + return 'Submitted'; + } +} + +function normalizeCaseRecord(input, existingRecord = null) { + if (!isPlainObject(input)) { + throw new Error('Case payload must be a JSON object.'); + } + + const caseId = String(input.caseId || existingRecord?.caseId || input.report?.caseId || '').trim(); + if (!caseId) { + throw new Error('caseId is required.'); + } + + const merged = stripPhotoDataUrl(deepMerge(existingRecord || {}, input)); + const report = isPlainObject(merged.report) ? merged.report : {}; + const createdAt = existingRecord?.createdAt || report.submittedAt || merged.ts || merged.timestamp || nowIso(); + const updatedAt = nowIso(); + const timestamp = merged.timestamp || merged.ts || report.submittedAt || createdAt; + + const normalized = { + ...merged, + caseId, + createdAt, + updatedAt, + timestamp, + ts: merged.ts || timestamp, + status: deriveCaseStatus(merged), + report: { + ...report, + caseId, + }, + }; + + if (!normalized.statusUpdatedAt) { + normalized.statusUpdatedAt = updatedAt; + } + + return normalized; +} + +function findCaseIndex(store, caseId) { + return store.cases.findIndex((entry) => String(entry.caseId) === String(caseId)); +} + +function buildInitialStore() { + return { + cases: [], + audit: [], + updatedAt: nowIso(), + }; +} + +async function ensureStore() { + await fs.mkdir(DATA_DIR, { recursive: true }); + try { + await fs.access(STORE_PATH); + } catch { + await fs.writeFile(STORE_PATH, JSON.stringify(buildInitialStore(), null, 2), 'utf8'); + } +} + +async function readStore() { + await ensureStore(); + const raw = await fs.readFile(STORE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return { ...buildInitialStore(), cases: parsed }; + } + return { + ...buildInitialStore(), + ...parsed, + cases: Array.isArray(parsed.cases) ? parsed.cases : [], + audit: Array.isArray(parsed.audit) ? parsed.audit : [], + }; +} + +async function writeStore(store) { + const next = { + ...store, + cases: sortCasesAscending(store.cases || []), + updatedAt: nowIso(), + }; + const tmpPath = `${STORE_PATH}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); + await fs.rename(tmpPath, STORE_PATH); + return next; +} + +function queueStoreMutation(mutator) { + const task = storeWriteQueue.then(async () => { + const store = await readStore(); + const result = await mutator(store); + const writtenStore = await writeStore(store); + return { result, store: writtenStore }; + }); + storeWriteQueue = task.catch(() => undefined); + return task; +} + +async function readJsonBody(req) { + const chunks = []; + let total = 0; + + for await (const chunk of req) { + total += chunk.length; + if (total > MAX_BODY_SIZE) { + throw new Error('Request body is too large.'); + } + chunks.push(chunk); + } + + if (!chunks.length) return {}; + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return {}; + + try { + return JSON.parse(raw); + } catch { + throw new Error('Invalid JSON body.'); + } +} + +async function readBinaryBody(req, maxBytes = MAX_UPLOAD_BODY_SIZE) { + const chunks = []; + let total = 0; + + for await (const chunk of req) { + total += chunk.length; + if (total > maxBytes) { + throw new Error('Upload body is too large.'); + } + chunks.push(chunk); + } + + return Buffer.concat(chunks); +} + +// Open-source YOLOv8 pothole classifier (Python, local, no API cost). +// Runs as a PERSISTENT worker: the model loads once, then each request is fast +// (real-time) โ€” instead of cold-starting torch + the model on every call. +const PYTHON_BIN = process.env.PYTHON_BIN || 'python'; +const CLASSIFY_SERVER = path.join(__dirname, 'ai', 'pothole_server.py'); + +function extFromContentType(contentType = '') { + const ct = String(contentType).toLowerCase(); + if (ct.includes('png')) return '.png'; + if (ct.includes('webp')) return '.webp'; + return '.jpg'; +} + +let _yoloProc = null; // the persistent python worker +let _yoloReady = null; // Promise that resolves once the model is loaded +let _yoloQueue = []; // FIFO of in-flight requests (worker answers one line at a time) +let _yoloBuf = ''; + +function startYoloWorker() { + let readyResolve, readyReject; + _yoloReady = new Promise((res, rej) => { readyResolve = res; readyReject = rej; }); + _yoloBuf = ''; + + let proc; + try { + proc = spawn(PYTHON_BIN, [CLASSIFY_SERVER], { stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (err) { + _yoloProc = null; + readyReject(err); + return null; + } + + _yoloProc = proc; + + const rejectWorker = (err) => { + _yoloProc = null; + _yoloQueue.splice(0).forEach((j) => j.reject(err)); + readyReject(err); + }; + + proc.stdout.on('data', (chunk) => { + _yoloBuf += chunk.toString(); + let idx; + while ((idx = _yoloBuf.indexOf('\n')) >= 0) { + const line = _yoloBuf.slice(0, idx).trim(); + _yoloBuf = _yoloBuf.slice(idx + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch (_) { continue; } + if (msg && msg.ready) { readyResolve(true); continue; } + const job = _yoloQueue.shift(); // results come back in FIFO order + if (job) job.resolve(msg); + } + }); + proc.stderr.on('data', () => {}); // ultralytics logs to stderr - ignore + proc.on('error', (err) => { + rejectWorker(err); + }); + proc.on('exit', (code) => { + rejectWorker(new Error(`classifier worker exited (code ${code})`)); + }); + return proc; +} + +function ensureYoloWorker() { + if (!_yoloProc) startYoloWorker(); + return _yoloReady; +} + +async function classifyPotholeImage(buffer, contentType) { + const tmpPath = path.join(os.tmpdir(), `potholeiq-${Date.now()}-${Math.random().toString(36).slice(2)}${extFromContentType(contentType)}`); + await fs.writeFile(tmpPath, buffer); + try { + await ensureYoloWorker(); + return await new Promise((resolve, reject) => { + const job = {}; + const timer = setTimeout(() => { + const i = _yoloQueue.indexOf(job); + if (i >= 0) _yoloQueue.splice(i, 1); + reject(new Error('classifier timeout')); + }, 60000); + job.resolve = (v) => { clearTimeout(timer); resolve(v); }; + job.reject = (e) => { clearTimeout(timer); reject(e); }; + _yoloQueue.push(job); + _yoloProc.stdin.write(tmpPath + '\n'); + }); + } finally { + fs.unlink(tmpPath).catch(() => {}); + } +} + +// โ”€โ”€ Backend document generation (Review Sheet + Work Order) with embedded photo โ”€โ”€ +// Box Doc Gen can't reliably embed an image-from-URL, so we render the .docx here. +const GEN_DOC_SCRIPT = path.join(__dirname, 'ai', 'gen_doc.py'); +const GEN_WORKORDER_SCRIPT = path.join(__dirname, 'ai', 'gen_workorder.py'); +const GEN_CLOSEOUT_SCRIPT = path.join(__dirname, 'ai', 'gen_closeout.py'); + +// Closeout Job Sheet spec โ€” only the crew-form fields (blank when dispatched for the crew to fill). +function buildCloseoutSpec(caseId, m = {}, imagePath = null) { + return { + caseId, + repairCompleted: m.repairCompleted || '', + repairMethods: m.repairMethods || '', + materialsUsed: m.materialsUsed || '', + laborHours: m.laborHours || '', + crewName: m.crewName || '', + crewContact: m.crewContact || '', + reportedPhotoPath: imagePath || undefined, // citizen's pre-repair photo (the crew sees what to fix) + city: 'City of Philadelphia', + department: 'Department of Streets ยท Road Maintenance Division', + }; +} + +async function runDocGen(scriptPath, spec) { + const base = path.join(os.tmpdir(), `pothole-doc-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const specPath = `${base}.json`; + const outPath = `${base}.docx`; + await fs.writeFile(specPath, JSON.stringify(spec)); + try { + await new Promise((resolve, reject) => { + execFile(PYTHON_BIN, [scriptPath, specPath, outPath], { timeout: 60000, maxBuffer: 1024 * 1024 }, (err, out, errOut) => { + if (err && !out) { reject(new Error(`doc generation failed: ${err.message}${errOut ? ` | ${errOut}` : ''}`)); return; } + resolve(out); + }); + }); + return await fs.readFile(outPath); + } finally { + fs.unlink(specPath).catch(() => {}); + fs.unlink(outPath).catch(() => {}); + } +} + +function priorityFromSeverity(score) { + const s = Number(score) || 0; + if (s >= 80) return 'P1 โ€” Emergency'; + if (s >= 60) return 'P2 โ€” High'; + if (s >= 40) return 'P3 โ€” Medium'; + return 'P4 โ€” Routine'; +} + +function buildDocSpec(docType, caseId, m = {}, imagePath = null) { + const lat = m.latitude != null ? m.latitude : (m.location && m.location.lat != null ? m.location.lat : ''); + const lng = m.longitude != null ? m.longitude : (m.location && m.location.lng != null ? m.location.lng : ''); + const sev = m.severityScore != null ? m.severityScore : (m.severityScore0100 != null ? m.severityScore0100 : ''); + const common = { accent: '0061D5', imagePath }; + if (docType === 'workorder' || docType === 'work_order') { + return { + ...common, + title: 'Pothole Repair Work Order', + subtitle: 'Department of Streets ยท Road Maintenance Dispatch', + meta: [['Work Order', `WO-${caseId}`], ['Case ID', caseId], ['Generated', new Date().toLocaleString()]], + sections: [ + { heading: 'Location', fields: [ + ['Case ID', caseId], ['Address', m.address], ['Ward', m.ward], ['District', m.district], + ['Maintenance Zone', m.maintenanceZone], ['Coordinates', `${lat}, ${lng}`] ] }, + { heading: 'Repair Details', fields: [ + ['Severity', `${m.severityLevel || ''} (${sev}/100)`], ['Pothole Size', m.potholeSize], + ['Priority', priorityFromSeverity(sev)], ['Weather Risk', m.weatherRisk], + ['Assigned Crew', m.assignedCrew || m.crewName || 'Pending dispatch'], + ['Target Completion', m.targetDate || '________________'] ] }, + ], + imageCaption: 'Reported pothole โ€” verify location and extent on arrival.', + notes: 'Crew: complete the repair, capture an AFTER photo, record materials used + labor hours, then submit closeout.\n\nCrew signature: ________________ Supervisor sign-off: ________________ Date: __________', + }; + } + return { + ...common, + title: 'Pothole Case Review Sheet', + subtitle: 'Department of Streets ยท District Engineer Review', + meta: [['Case ID', caseId], ['Generated', new Date().toLocaleString()]], + sections: [ + { heading: 'Case Information', fields: [ + ['Case ID', caseId], ['Submitted At', m.submittedAt], ['Address', m.address], + ['Ward', m.ward], ['District', m.district], ['Maintenance Zone', m.maintenanceZone], + ['Latitude', lat], ['Longitude', lng] ] }, + { heading: 'AI Assessment & Severity', fields: [ + ['Severity Score', `${sev} / 100`], ['Severity Level', m.severityLevel], + ['Pothole Size', m.potholeSize], ['Duplicate Status', m.duplicateStatus], + ['Weather Risk', m.weatherRisk], ['AI Review Status', m.aiReviewStatus], + ['Reporter Contact', m.reporterContact] ] }, + ], + imageCaption: 'Citizen-submitted photo (AI-classified).', + notes: 'Reviewer Decision: [ ] Approve & Dispatch [ ] Reject\n\nEngineer: ________________ Date: __________', + }; +} + +// Map case metadata to the realistic Work Order form spec (ai/gen_workorder.py). +function buildWorkOrderSpec(caseId, m = {}, imagePath = null) { + const lat = m.latitude != null ? m.latitude : (m.location && m.location.lat != null ? m.location.lat : ''); + const lng = m.longitude != null ? m.longitude : (m.location && m.location.lng != null ? m.location.lng : ''); + const sev = m.severityScore != null ? m.severityScore : (m.severityScore0100 != null ? m.severityScore0100 : ''); + return { + workOrderId: `WO-${caseId}`, + caseId, + issuedDate: new Date().toLocaleDateString(), + priority: priorityFromSeverity(sev), + status: 'OPEN', + reportedDate: m.submittedAt ? new Date(m.submittedAt).toLocaleDateString() : '', + reportedBy: m.reporterContact || 'Citizen', + source: 'Citizen Portal (PotholeIQ)', + aiReviewStatus: m.aiReviewStatus || '', + duplicateStatus: m.duplicateStatus || '', + address: m.address || '', + ward: m.ward || '', + district: m.district || '', + zone: m.maintenanceZone || '', + coords: (lat !== '' || lng !== '') ? `${lat}, ${lng}` : '', + defectType: 'Pothole', + size: m.potholeSize || '', + severityScore: sev !== '' ? `${sev} / 100` : '', + severityLevel: m.severityLevel || '', + weatherRisk: m.weatherRisk || '', + roadSurface: m.roadSurface || 'Asphalt', + assignedCrew: m.assignedCrew || m.crewName || '', + scheduledDate: m.scheduledDate || '', + targetDate: m.targetDate || '', + estHours: m.estHours || '', + authorizedBy: m.authorizedBy || 'District Engineer', + imagePath, + city: 'City of Philadelphia', + department: 'Department of Streets โ€” Road Maintenance Division', + }; +} + +// Map a stored case report onto the fields the Work Order document expects. +function buildWorkOrderMeta(report = {}) { + return { + address: report.address, + ward: report.ward, + district: report.district, + maintenanceZone: report.maintenanceZone, + latitude: report.location?.lat ?? report.latitude, + longitude: report.location?.lng ?? report.longitude, + severityScore: report.enrichment?.severityScore ?? report.severityScore ?? report.severityScore0100, + severityLevel: report.enrichment?.severityLevel ?? report.severityLevel, + potholeSize: report.potholeSize || report.supervisor?.size || report.aiResult, + weatherRisk: report.weatherRisk, + crewName: report.dispatchPlan?.crewName || report.crewName || report.assignedTo, + targetDate: report.dispatchPlan?.targetDate || report.targetDate, + photoFileId: report.photoFileId, + }; +} + +// Canonicalize the dashboard's status string to the Title-cased form the store uses. +function canonicalStatus(raw) { + const s = String(raw || '').trim().toLowerCase(); + const map = { + submitted: 'Submitted', + 'under review': 'Under Review', + assigned: 'Assigned', + 'in progress': 'In Progress', + resolved: 'Resolved', + rejected: 'Rejected', + }; + return map[s] || (raw ? String(raw).trim() : ''); +} + +// Render a case document (review|workorder) with the photo embedded and upload it to Box. +async function generateCaseDocument({ caseId, docType, metadata = {} }) { + const photoFileId = String(metadata.photoFileId || '').trim(); + let imagePath = null; + if (photoFileId) { + try { + const f = await boxService.getFileContent(photoFileId); + imagePath = path.join(os.tmpdir(), `pothole-img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`); + await fs.writeFile(imagePath, f.body); + } catch (e) { + console.warn('[doc] could not fetch photo, generating without image:', e.message); + } + } + try { + const isWO = docType === 'workorder' || docType === 'work_order'; + const isCO = docType === 'closeout'; + let buffer, fileName; + if (isCO) { + buffer = await runDocGen(GEN_CLOSEOUT_SCRIPT, buildCloseoutSpec(caseId, metadata, imagePath)); + fileName = `${caseId}_closeout.docx`; + } else if (isWO) { + buffer = await runDocGen(GEN_WORKORDER_SCRIPT, buildWorkOrderSpec(caseId, metadata, imagePath)); + fileName = `${caseId}_work_order.docx`; + } else { + buffer = await runDocGen(GEN_DOC_SCRIPT, buildDocSpec('review', caseId, metadata, imagePath)); + fileName = `${caseId}_review_sheet.docx`; + } + const result = await boxService.uploadGeneratedDoc({ + caseId, + caseRecord: { caseId, report: metadata }, + fileName, + buffer, + }); + return { ...result, docType: isCO ? 'closeout' : (isWO ? 'workorder' : 'review') }; + } finally { + if (imagePath) fs.unlink(imagePath).catch(() => {}); + } +} + +// Map a stored case record (with nested .report) to the flat row db.upsertCase expects. +function caseToDbRow(rec = {}) { + const r = rec.report || {}; + const loc = r.location || {}; + const enr = r.enrichment || {}; + return { + caseId: rec.caseId, + status: rec.status, + submittedAt: r.submittedAt || rec.createdAt || null, + resolvedAt: r.resolvedAt || null, + address: r.address, + ward: r.ward || enr.district?.ward, + district: r.district || enr.district?.district, + maintenanceZone: r.maintenanceZone || enr.district?.maintenanceZone, + latitude: loc.lat ?? r.latitude, + longitude: loc.lng ?? r.longitude, + severityScore: enr.severityScore ?? r.severityScore, + severityLevel: enr.severityLevel || r.severityLevel, + potholeSize: r.aiResult || r.potholeSize, + duplicateStatus: enr.duplicate?.isDuplicate ? 'duplicate' : (r.duplicateStatus || 'new'), + duplicateCount: enr.duplicate?.duplicateCount ?? 0, + linkedCaseIds: Array.isArray(enr.duplicate?.linkedCaseIds) ? enr.duplicate.linkedCaseIds.join(', ') : (r.linkedCaseIds || ''), + weatherRisk: enr.severityBreakdown?.weather?.riskLevel || r.weatherRisk, + aiReviewStatus: r.aiReviewStatus, + reporterContact: r.reporterContact, + photoUrl: r.photoUrl, + boxFolderId: r.boxFolderId, + assignedCrew: r.crewName || r.assignedTo, + materialsUsed: r.materialsUsed, + laborHours: r.laborHours, + crewNotes: r.resolutionNotes || r.notes, + afterPhotoUrl: r.afterPhotoUrl, + raw: rec, + }; +} + +async function mirrorCaseToDb(record) { + if (!db.isEnabled() || !record?.caseId) return; + try { + await db.upsertCase(caseToDbRow(record)); + } catch (error) { + console.warn('[db] mirror failed for', record.caseId, '-', error.message); + } +} + +function buildBoxUpdatePatch(payload, existingRecord = null) { + const workflowStage = String(payload.workflowStage || payload.stage || payload.report?.workflowStage || '').trim(); + const workflowStatus = String(payload.workflowStatus || payload.status || payload.report?.workflowStatus || '').trim(); + const workflowPriority = String(payload.workflowPriority || payload.priority || payload.report?.workflowPriority || '').trim(); + const nextAction = String(payload.workflowNextAction || payload.nextAction || payload.report?.workflowNextAction || '').trim(); + const folderId = String(payload.folderId || payload.boxFolderId || payload.caseFolderId || '').trim(); + const boxPath = String(payload.boxPath || payload.folderPath || '').trim(); + + const reportPatch = { + workflowStage, + workflowStatus, + workflowPriority, + workflowNextAction: nextAction, + workflowLastUpdatedAt: nowIso(), + assignedTo: payload.assignedTo || payload.assignee || existingRecord?.report?.assignedTo || '', + resolvedAt: payload.resolvedAt || payload.completionTimestamp || existingRecord?.report?.resolvedAt || '', + resolutionNotes: payload.resolutionNotes || payload.notes || existingRecord?.report?.resolutionNotes || '', + boxFolderId: folderId || existingRecord?.report?.boxFolderId || '', + boxPath: boxPath || existingRecord?.report?.boxPath || '', + crewName: payload.crewName || existingRecord?.report?.crewName || '', + afterPhotoFileId: payload.afterPhotoFileId || existingRecord?.report?.afterPhotoFileId || '', + materialsUsed: payload.materialsUsed || existingRecord?.report?.materialsUsed || '', + laborHours: payload.laborHours || existingRecord?.report?.laborHours || '', + crewSignature: payload.crewSignature || existingRecord?.report?.crewSignature || '', + boxAutomate: { + lastEventAt: nowIso(), + rawPayload: payload, + }, + }; + + return { + caseId: String(payload.caseId || existingRecord?.caseId || '').trim(), + status: deriveCaseStatus({ + status: workflowStatus, + workflowStage, + workflowStatus, + }), + statusUpdatedAt: nowIso(), + report: reportPatch, + }; +} + +function validateBoxSecret(reqUrl, req) { + if (!BOX_AUTOMATE_SECRET) return true; + const headerSecret = String(req.headers['x-box-automate-secret'] || '').trim(); + const querySecret = String(reqUrl.searchParams.get('secret') || '').trim(); + return headerSecret === BOX_AUTOMATE_SECRET || querySecret === BOX_AUTOMATE_SECRET; +} + +function uniqueStrings(values = []) { + return Array.from(new Set( + values + .map((value) => String(value || '').trim()) + .filter(Boolean) + )); +} + +function normalizeRouteKey(value) { + return String(value || '').trim().toLowerCase(); +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function defaultAuthorityRouting() { + return { + default: { + authorityName: 'Road Maintenance Authority', + authorityEmails: ['roads@authority.local'], + dispatchEmails: ['dispatch@authority.local'], + }, + districts: {}, + zones: {}, + }; +} + +async function readAuthorityRouting() { + try { + const raw = await fs.readFile(AUTHORITY_ROUTING_PATH, 'utf8'); + const parsed = JSON.parse(raw); + return { + ...defaultAuthorityRouting(), + ...parsed, + default: { + ...defaultAuthorityRouting().default, + ...(parsed.default || {}), + }, + districts: isPlainObject(parsed.districts) ? parsed.districts : {}, + zones: isPlainObject(parsed.zones) ? parsed.zones : {}, + }; + } catch { + return defaultAuthorityRouting(); + } +} + +function normalizeAuthorityRoute(route, source = {}) { + return { + authorityName: String(route?.authorityName || '').trim() || 'Road Maintenance Authority', + authorityEmails: uniqueStrings(route?.authorityEmails || []), + dispatchEmails: uniqueStrings(route?.dispatchEmails || []), + routeSource: String(source.type || 'default').trim() || 'default', + routeKey: String(source.key || '').trim(), + }; +} + +function resolveAuthorityRoute(caseRecord, routingConfig) { + const routing = routingConfig || defaultAuthorityRouting(); + const report = caseRecord?.report || {}; + + const zoneCandidates = uniqueStrings([ + report.maintenanceZone, + report.workflowMaintenanceZone, + report.gisMapping?.zone, + ]); + for (const zone of zoneCandidates) { + const key = normalizeRouteKey(zone); + if (key && isPlainObject(routing.zones?.[key])) { + return normalizeAuthorityRoute(routing.zones[key], { type: 'zone', key: zone }); + } + } + + const districtCandidates = uniqueStrings([ + report.district, + report.workflowDistrict, + report.gisMapping?.district, + ]); + for (const district of districtCandidates) { + const key = normalizeRouteKey(district); + if (key && isPlainObject(routing.districts?.[key])) { + return normalizeAuthorityRoute(routing.districts[key], { type: 'district', key: district }); + } + } + + return normalizeAuthorityRoute(routing.default, { type: 'default', key: 'default' }); +} + +function buildAuthorityNoticeArtifact(caseRecord, authorityRoute) { + const report = caseRecord.report || {}; + const severityScore = report.enrichment?.severityScore ?? report.workflowSeverityScore ?? 'N/A'; + const severityLevel = report.enrichment?.severityLevel || report.workflowSeverityLevel || 'Pending'; + const duplicateStatus = report.duplicateStatus + || (report.enrichment?.duplicate?.isDuplicate ? 'duplicate' : 'new') + || 'pending'; + const routeEmails = uniqueStrings([...(authorityRoute.authorityEmails || []), ...(authorityRoute.dispatchEmails || [])]); + const fileName = `${caseRecord.caseId}_authority_notice.md`; + const content = `# Authority Notification - ${caseRecord.caseId} + +- Generated At: ${nowIso()} +- Authority: ${authorityRoute.authorityName} +- Delivery Targets: ${routeEmails.join(', ') || 'No contacts configured'} +- Status: Generated for external sending + +## Case Snapshot +- Case ID: ${caseRecord.caseId} +- Address: ${report.address || 'Unknown'} +- Ward: ${report.ward || 'Pending'} +- District: ${report.district || 'Pending'} +- Maintenance Zone: ${report.maintenanceZone || 'Pending'} +- Area Label: ${report.areaLabel || 'Pending'} +- Latitude / Longitude: ${report.location?.lat ?? 'N/A'}, ${report.location?.lng ?? 'N/A'} + +## Severity & Review +- Severity Score: ${severityScore} +- Severity Level: ${severityLevel} +- Pothole Size: ${report.potholeSize || report.aiResult || report.aiPrediction || 'Unknown'} +- Weather Status: ${report.weatherStatus || report.enrichment?.weather?.riskLevel || 'Pending'} +- Duplicate Status: ${duplicateStatus} +- Manual Review Status: ${report.aiReviewStatus || 'Pending'} +- Supervisor: ${report.supervisor?.by || 'Pending'} + +## Action Requested +Please acknowledge this active pothole case, confirm ownership, and proceed with work-order handling and crew assignment. +`; + + return { + fileName, + content, + contentType: 'text/markdown; charset=utf-8', + payload: { + authorityName: authorityRoute.authorityName, + authorityEmails: routeEmails, + generatedAt: nowIso(), + status: 'generated', + routeSource: authorityRoute.routeSource, + routeKey: authorityRoute.routeKey, + }, + }; +} + +function buildRejectionArtifact(caseRecord) { + const report = caseRecord.report || {}; + const supervisor = report.supervisor || {}; + const fileName = `${caseRecord.caseId}_rejection_note.md`; + const content = `# Rejection Note - ${caseRecord.caseId} + +- Rejected At: ${supervisor.at || nowIso()} +- Rejected By: ${supervisor.by || 'Supervisor'} +- Reason: ${supervisor.reason || 'No reason provided'} + +## Case Context +- Address: ${report.address || 'Unknown'} +- Ward / District: ${report.ward || 'Pending'} / ${report.district || 'Pending'} +- AI Review Status: ${report.aiReviewStatus || 'Pending'} +- Pothole Size: ${report.potholeSize || report.aiResult || report.aiPrediction || 'Unknown'} +`; + + return { + fileName, + content, + contentType: 'text/markdown; charset=utf-8', + }; +} + +function buildWorkOrderArtifact(caseRecord, authorityRoute) { + const report = caseRecord.report || {}; + const supervisor = report.supervisor || {}; + const severityScore = report.enrichment?.severityScore ?? report.workflowSeverityScore ?? 'N/A'; + const severityLevel = report.enrichment?.severityLevel || report.workflowSeverityLevel || 'Pending'; + const crewName = report.dispatchPlan?.crewName || report.crewName || report.assignedTo || 'Pending dispatch'; + const workOrderId = report.workOrderId || `WO-${caseRecord.caseId}`; + const fileName = `${caseRecord.caseId}_work_order.html`; + const content = ` + + + + Work Order ${escapeHtml(workOrderId)} + + + +
+
+
Pothole Repair Work Order
+
Department of Streets / Road Maintenance
+
+
+
Work Order ID: ${escapeHtml(workOrderId)}
+
Generated: ${escapeHtml(nowIso())}
+
Authority: ${escapeHtml(authorityRoute.authorityName)}
+
+
+ +
+

Case Information

+
+
Case ID${escapeHtml(caseRecord.caseId)}
+
Status${escapeHtml(caseRecord.status || 'Under Review')}
+
Address${escapeHtml(report.address || 'Unknown')}
+
Ward / District${escapeHtml(`${report.ward || 'Pending'} / ${report.district || 'Pending'}`)}
+
Maintenance Zone${escapeHtml(report.maintenanceZone || 'Pending')}
+
Coordinates${escapeHtml(`${report.location?.lat ?? 'N/A'}, ${report.location?.lng ?? 'N/A'}`)}
+
Severity${escapeHtml(`${severityLevel} (${severityScore})`)}
+
Pothole Size${escapeHtml(report.potholeSize || supervisor.size || report.aiResult || report.aiPrediction || 'Unknown')}
+
+
+ +
+

Assignment & Approval

+
+
Assigned Crew${escapeHtml(crewName)}
+
Supervisor${escapeHtml(supervisor.by || 'Pending')}
+
Supervisor Decision Time${escapeHtml(supervisor.at || 'Pending')}
+
Authority Contact${escapeHtml(uniqueStrings([...(authorityRoute.authorityEmails || []), ...(authorityRoute.dispatchEmails || [])]).join(', ') || 'Not configured')}
+
+
+ +
+

Repair Notes

+
+ Instructions + Inspect the location, complete the repair, capture an after photo, record materials used, labor hours, and crew signature, then submit closeout through the crew closeout page. +
+
+ +`; + + return { + fileName, + content, + contentType: 'text/html; charset=utf-8', + payload: { + workOrderId, + generatedAt: nowIso(), + authorityName: authorityRoute.authorityName, + }, + }; +} + +function buildDispatchAssignmentArtifact(caseRecord) { + const report = caseRecord.report || {}; + const dispatchPlan = report.dispatchPlan || {}; + const fileName = `${caseRecord.caseId}_dispatch_assignment.md`; + const content = `# Dispatch Assignment - ${caseRecord.caseId} + +- Dispatch Plan ID: ${dispatchPlan.planId || 'Not available'} +- Sent At: ${dispatchPlan.sentAt || nowIso()} +- Assigned Crew: ${dispatchPlan.crewName || report.crewName || report.assignedTo || 'Pending'} +- Route Stop: ${dispatchPlan.stopIndex || 'N/A'} of ${dispatchPlan.totalStops || 'N/A'} +- Coverage Wards: ${Array.isArray(dispatchPlan.selectedWards) && dispatchPlan.selectedWards.length ? dispatchPlan.selectedWards.join(', ') : 'Not specified'} + +## Case Snapshot +- Case ID: ${caseRecord.caseId} +- Current Status: ${caseRecord.status || 'Assigned'} +- Address: ${report.address || 'Unknown'} +- Ward / District: ${report.ward || 'Pending'} / ${report.district || 'Pending'} +- Maintenance Zone: ${report.maintenanceZone || 'Pending'} +- Latitude / Longitude: ${report.location?.lat ?? 'N/A'}, ${report.location?.lng ?? 'N/A'} +- Severity Level: ${report.enrichment?.severityLevel || report.workflowSeverityLevel || 'Pending'} +- Severity Score: ${report.enrichment?.severityScore ?? report.workflowSeverityScore ?? 'N/A'} + +## Field Instruction +Proceed to the assigned stop in route order, complete the repair, capture after-photo evidence, and submit the closeout record through the crew closeout page. +`; + + return { + fileName, + content, + contentType: 'text/markdown; charset=utf-8', + }; +} + +// Estimated repair cost from size + severity + road class (mirrors the Ops Review +// dashboard estimator) so the report can compare estimated vs. actual spend. +function estimateRepairCost(size, severityScore, address) { + const baseBySize = { Small: 140, Medium: 260, Large: 480 }; + const base = baseBySize[size] || 0; + if (!base) return { base: 0, severity: 0, traffic: 0, total: 0 }; + const score = Math.max(0, Math.min(100, Number(severityScore) || 50)); + const severity = Math.round((score / 100) * 80); + const isHighTraffic = /(broad st|market st|chestnut st|ridge ave|main road|ring road|highway|expressway)/i.test(String(address || '')); + const traffic = isHighTraffic ? Math.round(base * 0.12) : 0; + return { base, severity, traffic, total: Math.round((base + severity + traffic) / 5) * 5 }; +} + +function num(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function buildBeforeAfterArtifact(caseRecord) { + const report = caseRecord.report || {}; + const supervisor = report.supervisor || {}; + const enrichment = report.enrichment || {}; + const breakdown = enrichment.severityBreakdown || {}; + const loc = report.location || {}; + + const size = report.supervisor?.size || report.potholeSize || report.aiResult || report.aiPrediction || 'Unknown'; + const severityScore = enrichment.severityScore ?? report.workflowSeverityScore ?? ''; + const severityLevel = enrichment.severityLevel || report.workflowSeverityLevel || 'Pending'; + + const estimate = estimateRepairCost(size, severityScore, report.address); + const laborHours = num(report.laborHours); + const laborRate = num(report.laborRate); + const materialsCost = num(report.materialsCost); + const actualTotal = num(report.totalCost) || (laborHours * laborRate + materialsCost); + const variance = estimate.total ? actualTotal - estimate.total : 0; + const variancePct = estimate.total ? ((variance / estimate.total) * 100).toFixed(1) : 'N/A'; + const money = (v) => `$${num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + + const fileName = `${caseRecord.caseId}_before_after_report.md`; + const content = `# Before vs After Report โ€” ${caseRecord.caseId} + +Generated: ${new Date().toLocaleString()} +Status: ${caseRecord.status || 'Resolved'} + +## 1. Report & Location +- Case ID: ${caseRecord.caseId} +- Submitted At: ${report.submittedAt || caseRecord.createdAt || 'N/A'} +- Reporter: ${report.anonymous ? 'Anonymous' : (report.reporterName || 'N/A')}${report.reporterContact ? ` (${report.reporterContact})` : ''} +- Address: ${report.address || 'Unknown'} +- Coordinates: ${loc.lat != null && loc.lng != null ? `${loc.lat}, ${loc.lng}` : 'N/A'} +- Ward / District: ${report.ward || 'Pending'} / ${report.district || 'Pending'} +- Maintenance Zone: ${report.maintenanceZone || 'Pending'} + +## 2. AI Assessment (Before) +- AI Classification (size): ${report.aiResult || report.aiPrediction || 'Unknown'} +- Road-likeness Score: ${typeof report.aiRoadScore === 'number' ? (report.aiRoadScore * 100).toFixed(1) + '%' : 'N/A'} +- AI Review Status: ${report.aiReviewStatus || 'N/A'} +- Severity Score: ${severityScore === '' ? 'N/A' : `${severityScore} / 100`} (${severityLevel}) +- Severity Breakdown: + - 311 Prior Notice: ${breakdown.priorNotice?.score ?? 'N/A'} / ${breakdown.priorNotice?.max ?? 30} + - Traffic & Road Class: ${breakdown.traffic?.score ?? 'N/A'} / ${breakdown.traffic?.max ?? 25} + - Damage Type: ${breakdown.damage?.score ?? 'N/A'} / ${breakdown.damage?.max ?? 25} + - Weather Risk: ${breakdown.weather?.score ?? 'N/A'} / ${breakdown.weather?.max ?? 10} +- Duplicate Status: ${enrichment.duplicate?.isDuplicate ? `Duplicate (${enrichment.duplicate.duplicateCount} linked)` : 'New Case'} +- Supervisor Decision: ${supervisor.verified ? (supervisor.isPothole ? 'Approved' : 'Rejected') : 'Pending'} by ${supervisor.by || 'Supervisor'}${supervisor.at ? ` on ${new Date(supervisor.at).toLocaleString()}` : ''} + +## 3. Repair Completion (After) +- Resolved At: ${report.resolvedAt || 'Pending'} +- Crew Name: ${report.crewName || report.dispatchPlan?.crewName || report.assignedTo || 'Pending'} +- Repair Method: ${report.repairMethod || 'Not specified'} +- Materials Used: ${report.materialsUsed || 'Not specified'} +- Labor Hours: ${report.laborHours || 'Not specified'} +- Crew Signature: ${report.crewSignature || 'Not provided'} +- Before Photo (File ID): ${report.photoFileId || 'N/A'} +- After Photo (File ID): ${report.afterPhotoFileId || 'Not uploaded'} + +## 4. Cost โ€” Estimated vs. Actual +| | Amount | +|---|---| +| Estimated (base ${money(estimate.base)} + severity ${money(estimate.severity)} + traffic ${money(estimate.traffic)}) | ${money(estimate.total)} | +| Actual labor (${laborHours}h ร— ${money(laborRate)}/hr) | ${money(laborHours * laborRate)} | +| Actual materials | ${money(materialsCost)} | +| **Actual total spend** | **${money(actualTotal)}** | +| Variance vs. estimate | ${variance >= 0 ? '+' : ''}${money(variance)} (${variancePct === 'N/A' ? 'N/A' : variancePct + '%'}) | + +## 5. Field Notes +${report.resolutionNotes || 'No field notes provided.'} +`; + + return { + fileName, + content, + contentType: 'text/markdown; charset=utf-8', + }; +} + +// Build the spec for a Before/After .docx report (rendered by ai/gen_doc.py) โ€” all +// the dashboard + form data plus the before & after photos embedded inline. +function buildBeforeAfterDocSpec(caseRecord, images = []) { + const report = caseRecord.report || {}; + const sup = report.supervisor || {}; + const enr = report.enrichment || {}; + const bd = enr.severityBreakdown || {}; + const dist = enr.district || {}; + // Coordinates: prefer report.location, fall back to flat lat/lng. + const loc = (report.location && report.location.lat != null) + ? report.location + : ((report.latitude != null && report.longitude != null) ? { lat: report.latitude, lng: report.longitude } : {}); + // GIS fields: flat report value, else the enrichment-mapped district, ignoring the + // "Ward-Unknown"/"Unassigned"/"Pending" placeholders. + const realVal = (...vals) => { + for (const v of vals) { + const s = String(v == null ? '' : v).trim(); + if (s && !/^ward[-\s]?unknown$/i.test(s) && !/^unassigned$/i.test(s) && !/^pending/i.test(s)) return s; + } + return ''; + }; + const wardVal = realVal(report.ward, dist.ward); + const districtVal = realVal(report.district, dist.district); + const zoneVal = realVal(report.maintenanceZone, dist.zone, dist.maintenanceZone); + const reporterName = realVal(report.reporterName); + const reporterContact = realVal(report.reporterContact); + const isAnon = reporterContact || reporterName ? false : !!report.anonymous; + + const size = sup.size || report.potholeSize || report.aiResult || report.aiPrediction || 'Unknown'; + const sevScore = enr.severityScore ?? report.workflowSeverityScore ?? ''; + const sevLevel = enr.severityLevel || report.workflowSeverityLevel || 'Pending'; + + const estimate = estimateRepairCost(size, sevScore, report.address); + const laborHours = num(report.laborHours), laborRate = num(report.laborRate), materialsCost = num(report.materialsCost); + const actualTotal = num(report.totalCost) || (laborHours * laborRate + materialsCost); + const variance = estimate.total ? actualTotal - estimate.total : 0; + const variancePct = estimate.total ? `${((variance / estimate.total) * 100).toFixed(1)}%` : 'N/A'; + const money = (v) => `$${num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + + return { + accent: '0061D5', + title: 'Pothole Repair โ€” Before / After Report', + subtitle: 'Department of Streets ยท Road Maintenance Division', + meta: [ + ['Case ID', caseRecord.caseId], + ['Status', caseRecord.status || 'Resolved'], + ['Generated', new Date().toLocaleString()], + ], + images, + sections: [ + { heading: 'Report & Location', fields: [ + ['Submitted', report.submittedAt || caseRecord.createdAt || 'N/A'], + ['Reporter', isAnon ? 'Anonymous' : (reporterName || reporterContact || 'N/A')], + ['Reporter Contact', reporterContact || 'โ€”'], + ['Address', report.address || 'Unknown'], + ['Coordinates', (loc.lat != null && loc.lng != null) ? `${loc.lat}, ${loc.lng}` : 'N/A'], + ['Ward / District', `${wardVal || 'Pending'} / ${districtVal || 'Pending'}`], + ['Maintenance Zone', zoneVal || 'Pending'], + ] }, + { heading: 'AI Assessment & Severity (Before)', fields: [ + ['AI Classification (Size)', report.aiResult || report.aiPrediction || 'Unknown'], + ['Road-likeness Score', typeof report.aiRoadScore === 'number' ? `${(report.aiRoadScore * 100).toFixed(1)}%` : 'N/A'], + ['Severity', `${sevLevel}${sevScore !== '' ? ` (${sevScore}/100)` : ''}`], + ['311 Prior Notice', `${bd.priorNotice?.score ?? 'N/A'} / ${bd.priorNotice?.max ?? 30}`], + ['Traffic & Road Class', `${bd.traffic?.score ?? 'N/A'} / ${bd.traffic?.max ?? 25}`], + ['Damage Type', `${bd.damage?.score ?? 'N/A'} / ${bd.damage?.max ?? 25}`], + ['Weather Risk', `${bd.weather?.score ?? 'N/A'} / ${bd.weather?.max ?? 10}`], + ['Duplicate Status', enr.duplicate?.isDuplicate ? `Duplicate (${enr.duplicate.duplicateCount} linked)` : 'New case'], + ['Supervisor Decision', `${sup.verified ? (sup.isPothole ? 'Approved' : 'Rejected') : 'Pending'} ยท ${sup.by || 'โ€”'}`], + ] }, + { heading: 'Repair Completion (After)', fields: [ + ['Resolved At', report.resolvedAt || 'Pending'], + ['Crew', report.crewName || report.dispatchPlan?.crewName || report.assignedTo || 'Pending'], + ['Repair Method', report.repairMethod || 'Not specified'], + ['Materials Used', report.materialsUsed || 'Not specified'], + ['Labor Hours', report.laborHours ? `${report.laborHours} h` : 'Not specified'], + ['Crew Signature', report.crewSignature || 'Not provided'], + ] }, + { heading: 'Cost โ€” Estimated vs. Actual', fields: [ + ['Estimated Cost', `${money(estimate.total)} (base ${money(estimate.base)} + severity ${money(estimate.severity)} + traffic ${money(estimate.traffic)})`], + ['Actual Labor', `${money(laborHours * laborRate)} (${laborHours} h ร— ${money(laborRate)}/hr)`], + ['Actual Materials', money(materialsCost)], + ['Actual Total Spend', money(actualTotal)], + ['Variance vs. Estimate', `${variance >= 0 ? '+' : ''}${money(variance)} (${variancePct})`], + ] }, + ], + notes: report.resolutionNotes || 'No field notes provided.', + }; +} + +// Generate the Before/After .docx (photos embedded) and upload it to the Box case +// folder. Returns the upload result (fileId/fileName) so the closeout can link/notify. +async function generateBeforeAfterDoc({ caseId, caseRecord }) { + const report = caseRecord.report || {}; + const tmpFiles = []; + const images = []; + + async function addPhoto(fileId, caption) { + const id = String(fileId || '').trim(); + if (!id) return; + try { + const f = await boxService.getFileContent(id); + const p = path.join(os.tmpdir(), `pothole-ba-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`); + await fs.writeFile(p, f.body); + tmpFiles.push(p); + images.push({ path: p, caption }); + } catch (e) { + console.warn('[before-after] could not fetch photo', id, e.message); + } + } + + await addPhoto(report.photoFileId, 'BEFORE โ€” Reported Condition'); + await addPhoto(report.afterPhotoFileId, 'AFTER โ€” Completed Repair'); + + try { + const spec = buildBeforeAfterDocSpec(caseRecord, images); + const buffer = await runDocGen(GEN_DOC_SCRIPT, spec); + const fileName = `${caseId}_before_after_report.docx`; + // Land in the dedicated "Closeout Reports" folder so watchers get one clean email. + const result = await boxService.uploadCloseoutReport({ + caseId, + fileName, + content: buffer, + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + return { ...result, fileName, embeddedImages: images.length }; + } finally { + tmpFiles.forEach((p) => fs.unlink(p).catch(() => {})); + } +} + +// โ”€โ”€ Before/After PDF generation (headless Chrome) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function findChromeExecutable() { + const candidates = [ + process.env.CHROME_PATH, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', + process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Google/Chrome/Application/chrome.exe') : '', + 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium', + ].filter(Boolean); + for (const c of candidates) { + try { if (require('fs').existsSync(c)) return c; } catch (_) {} + } + return ''; +} + +function fileUrl(p) { + return 'file:///' + String(p).replace(/\\/g, '/').replace(/^\/+/, ''); +} + +async function htmlToPdf(html) { + const chrome = findChromeExecutable(); + if (!chrome) throw new Error('No Chrome/Edge found for PDF rendering'); + const base = path.join(os.tmpdir(), `pothole-ba-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const htmlPath = `${base}.html`; + const pdfPath = `${base}.pdf`; + const profileDir = `${base}-prof`; + await fs.writeFile(htmlPath, html, 'utf8'); + try { + await new Promise((resolve, reject) => { + execFile(chrome, [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-sandbox', + `--user-data-dir=${profileDir}`, + '--print-to-pdf-no-header', + `--print-to-pdf=${pdfPath}`, + fileUrl(htmlPath), + ], { timeout: 60000 }, (err) => (err ? reject(err) : resolve())); + }); + return await fs.readFile(pdfPath); + } finally { + fs.unlink(htmlPath).catch(() => {}); + fs.unlink(pdfPath).catch(() => {}); + fs.rm(profileDir, { recursive: true, force: true }).catch(() => {}); + } +} + +// Render a before/after report spec (+ base64 photos) to printable HTML. +function renderBeforeAfterHtml(spec, images = []) { + const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); + const photos = images.filter((im) => im.dataUri); + const photoBlock = photos.length ? ` +
Before / After Photos
+
${photos.map((p) => `
${esc(p.caption)}
`).join('')}
+
` : ''; + const sections = (spec.sections || []).map((s) => ` +
${esc(s.heading)}
+ ${(s.fields || []).map(([l, v]) => ``).join('')}
${esc(l)}${esc(v)}
+
`).join(''); + const meta = (spec.meta || []).map(([l, v]) => `${esc(l)}: ${esc(v)}`).join('  ยท  '); + return ` +
${esc(spec.title)}
+
${esc(spec.subtitle)}
+
${meta}
+ ${photoBlock} + ${sections} +
Field Notes
${esc(spec.notes)}
+ `; +} + +// Generate the before/after report as a PDF (photos + all data) into the Closeout +// Reports folder. Falls back to the .docx generator if PDF rendering is unavailable. +async function generateBeforeAfterReport({ caseId, caseRecord }) { + const report = caseRecord.report || {}; + const images = []; + async function addPhoto(fileId, caption) { + const id = String(fileId || '').trim(); + if (!id) return; + try { + const f = await boxService.getFileContent(id); + images.push({ dataUri: `data:image/jpeg;base64,${Buffer.from(f.body).toString('base64')}`, caption }); + } catch (e) { + console.warn('[before-after pdf] photo fetch failed', id, e.message); + } + } + await addPhoto(report.photoFileId, 'Before โ€” Reported Condition'); + await addPhoto(report.afterPhotoFileId, 'After โ€” Completed Repair'); + + try { + const spec = buildBeforeAfterDocSpec(caseRecord, []); + const pdf = await htmlToPdf(renderBeforeAfterHtml(spec, images)); + const fileName = `${caseId}_before_after_report.pdf`; + const result = await boxService.uploadCloseoutReport({ + caseId, + fileName, + content: pdf, + contentType: 'application/pdf', + }); + return { ...result, fileName, embeddedImages: images.length, format: 'pdf' }; + } catch (e) { + console.warn('[before-after] PDF generation failed, falling back to .docx:', e.message); + return await generateBeforeAfterDoc({ caseId, caseRecord }); + } +} + +// โ”€โ”€ Box case-list cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// listBoxCases makes 2 Box API calls per case; even parallelized it's too slow to +// run on every dashboard poll. Serve a short-lived cache, refresh in the +// background, and bust it whenever a workflow writes a status back to Box. +const BOX_CASES_TTL_MS = Number(process.env.BOX_CASES_TTL_MS || 45000); +let _boxCases = { at: 0, cases: null, inflight: null }; + +function bustBoxCasesCache() { + _boxCases = { at: 0, cases: null, inflight: _boxCases.inflight }; +} + +async function getBoxCasesCached() { + const now = Date.now(); + if (_boxCases.cases && now - _boxCases.at < BOX_CASES_TTL_MS) { + return _boxCases.cases; + } + if (!_boxCases.inflight) { + _boxCases.inflight = boxService.listBoxCases() + .then((cases) => { + _boxCases = { at: Date.now(), cases, inflight: null }; + return cases; + }) + .catch((error) => { + _boxCases.inflight = null; + if (_boxCases.cases) return _boxCases.cases; // keep serving stale on error + throw error; + }); + } + // If we have stale data, serve it immediately while the refresh runs. + if (_boxCases.cases) return _boxCases.cases; + return _boxCases.inflight; +} + +// Split a free-text recipient string ("a@x.com, b@y.com; c@z.com") into addresses. +function parseRecipients(value) { + return String(value || '') + .split(/[,;\s]+/) + .map((s) => s.trim()) + .filter((s) => /.+@.+\..+/.test(s)); +} + +// De-duplicate emails case-insensitively while preserving original order/casing. +function dedupeEmails(list) { + const seen = new Set(); + const out = []; + for (const email of Array.isArray(list) ? list : []) { + const key = String(email || '').trim().toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(String(email).trim()); + } + return out; +} + +// Configured default recipients for closeout reports. Sources (merged): +// 1. env CLOSEOUT_NOTIFY_RECIPIENTS="a@x.gov, b@y.gov" (hosted deployments) +// 2. config.json -> closeout.notifyRecipients (local file config) +// These get the report on every closeout automatically; the form field can add more. +async function getCloseoutDefaultRecipients() { + const fromEnv = parseRecipients(process.env.CLOSEOUT_NOTIFY_RECIPIENTS || ''); + try { + const cfg = await configStore.readConfig(); + const fromFile = parseRecipients((cfg?.closeout?.notifyRecipients || []).join(',')); + return dedupeEmails([...fromEnv, ...fromFile]); + } catch { + return fromEnv; + } +} + +function isSupportedDemoImageFile(fileName) { + const extension = path.extname(String(fileName || '')).toLowerCase(); + return ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.heic'].includes(extension); +} + +async function readDemoImageManifest() { + try { + const entries = await fs.readdir(DEMO_IMAGE_DIR_PATH, { withFileTypes: true }); + const images = entries + .filter((entry) => entry.isFile() && isSupportedDemoImageFile(entry.name)) + .map((entry) => ({ + name: entry.name, + url: `/${[DEMO_IMAGE_DIR_NAME, entry.name].map((segment) => encodeURIComponent(segment)).join('/')}`, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + folder: DEMO_IMAGE_DIR_NAME, + images, + }; + } catch { + return { + folder: DEMO_IMAGE_DIR_NAME, + images: [], + }; + } +} + +async function handleApiRequest(req, res, reqUrl) { + if (req.method === 'OPTIONS') { + res.writeHead(204, withApiHeaders()); + res.end(); + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/health') { + const boxStatus = await boxService.getStatus(); + sendJson(res, 200, { + ok: true, + service: 'pothole-box-backend', + time: nowIso(), + workflowMode: 'database', + boxRole: 'content_repository', + boxAutomateSecretConfigured: !!BOX_AUTOMATE_SECRET, + legacyBoxAutomateCallbackConfigured: !!BOX_AUTOMATE_SECRET, + boxConfigured: !!boxStatus.configured, + }); + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/box/status') { + const status = await boxService.getStatus(); + sendJson(res, 200, { ok: true, status }); + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/demo-images') { + const manifest = await readDemoImageManifest(); + sendJson(res, 200, { ok: true, ...manifest }); + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/box/config') { + const config = await boxService.getClientConfig(); + const status = await boxService.getStatus(); + sendJson(res, 200, { ok: true, config, status }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/box/config') { + const payload = await readJsonBody(req); + const result = payload?.reset + ? await boxService.resetConfig() + : await boxService.saveConfig(payload?.box || payload || {}); + sendJson(res, 200, { ok: true, ...result }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/box/test-connection') { + const result = await boxService.testConnection(); + sendJson(res, result.ok ? 200 : 400, result); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/classify') { + const contentType = String(req.headers['content-type'] || 'image/jpeg').trim() || 'image/jpeg'; + try { + const fileBuffer = await readBinaryBody(req); + if (!fileBuffer.length) { + sendJson(res, 400, { ok: false, error: 'No image provided.' }); + return; + } + const result = await classifyPotholeImage(fileBuffer, contentType); + if (result.error) { + sendJson(res, 200, { ok: false, ...result }); + return; + } + sendJson(res, 200, { ok: true, ...result }); + } catch (error) { + sendJson(res, 200, { ok: false, error: error.message }); + } + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/box/upload-photo') { + const caseId = String(reqUrl.searchParams.get('caseId') || '').trim(); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required.' }); + return; + } + + const contentType = String(req.headers['content-type'] || 'image/jpeg').trim() || 'image/jpeg'; + const fileBuffer = await readBinaryBody(req); + const result = await boxService.uploadPhoto({ caseId, fileBuffer, contentType }); + sendJson(res, 200, { ok: true, ...result }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/box/upload-case-file') { + const caseId = String(reqUrl.searchParams.get('caseId') || '').trim(); + const fileName = String(reqUrl.searchParams.get('fileName') || '').trim(); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required.' }); + return; + } + if (!fileName) { + sendJson(res, 400, { ok: false, error: 'fileName is required.' }); + return; + } + + const store = await readStore(); + const caseRecord = store.cases.find((entry) => String(entry.caseId) === caseId); + if (!caseRecord) { + sendJson(res, 404, { ok: false, error: `Case "${caseId}" was not found.` }); + return; + } + + const contentType = String(req.headers['content-type'] || 'application/octet-stream').trim() || 'application/octet-stream'; + const fileBuffer = await readBinaryBody(req); + const result = await boxService.uploadCaseArtifact({ + caseId, + caseRecord, + fileName, + content: fileBuffer, + contentType, + }); + sendJson(res, 200, { ok: true, upload: result }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/box/upload-sidecar') { + const payload = await readJsonBody(req); + const caseId = String(payload.caseId || payload.metadata?.caseId || '').trim(); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required.' }); + return; + } + + const sidecarMetadata = payload.metadata || payload; + const result = await boxService.uploadSidecar({ + caseId, + metadata: sidecarMetadata, + }); + + // Record the case in the local store so the dashboard + supervisor-approve flow + // (which drives the Work Order) can find a portal-submitted case. + try { + const { result: stored } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + const existing = idx >= 0 ? store.cases[idx] : null; + const normalized = normalizeCaseRecord({ + caseId, + status: sidecarMetadata.status || 'Under Review', + report: { ...sidecarMetadata }, + }, existing); + if (idx >= 0) store.cases[idx] = normalized; else store.cases.push(normalized); + store.audit.push({ type: 'case_submitted', caseId, at: nowIso() }); + return normalized; + }); + await mirrorCaseToDb(stored); + } catch (e) { + console.warn(`[upload-sidecar] store upsert failed for ${caseId}:`, e.message); + } + + sendJson(res, 200, { + ok: true, + fileId: result.fileId, + fileName: result.fileName, + sharedLink: result.sharedLink, + sidecarPayload: result.sidecarPayload, + }); + + // After replying: (1) organize the case into a Box case folder (Potholes/{ward}/Case-{id}) + // with the metadata, so it shows on the Command Center AND the Box App; (2) generate the + // Review Sheet into that folder. Fire-and-forget โ€” runs server-side. + (async () => { + try { + await boxService.organizeCase({ + caseId, + ward: sidecarMetadata.ward, + district: sidecarMetadata.district || (sidecarMetadata.enrichment && sidecarMetadata.enrichment.district && sidecarMetadata.enrichment.district.district) || '', + photoFileId: sidecarMetadata.photoFileId, + sidecarFileId: result.fileId, + // buildReviewMetadata reads report fields from enrichment.report โ€” inject it so + // submittedAt / potholeSize / address / lat-lng land in the folder metadata. + enrichment: { ...(sidecarMetadata.enrichment || {}), report: sidecarMetadata }, + reportPayload: sidecarMetadata, + }); + console.log(`[upload-sidecar] organized case folder for ${caseId}`); + } catch (e) { + console.warn(`[upload-sidecar] organize failed for ${caseId}:`, e.message); + } + try { + const r = await generateCaseDocument({ caseId, docType: 'review', metadata: sidecarMetadata }); + console.log(`[upload-sidecar] review sheet generated for ${caseId}: ${r.fileName}`); + } catch (e) { + console.warn(`[upload-sidecar] review sheet generation failed for ${caseId}:`, e.message); + } + })(); + return; + } + + // Generate a Review Sheet or Work Order (.docx, photo embedded) and upload to Box. + if (req.method === 'POST' && reqUrl.pathname === '/api/box/generate-doc') { + const payload = await readJsonBody(req); + const caseId = String(payload.caseId || payload.metadata?.caseId || '').trim(); + if (!caseId) { sendJson(res, 400, { ok: false, error: 'caseId is required.' }); return; } + const docType = String(payload.docType || 'review').toLowerCase(); + const metadata = { ...(payload.metadata || {}) }; + if (payload.photoFileId) metadata.photoFileId = payload.photoFileId; + + try { + const result = await generateCaseDocument({ caseId, docType, metadata }); + sendJson(res, 200, { ok: true, ...result }); + } catch (e) { + sendJson(res, 500, { ok: false, error: e.message }); + } + return; + } + + // Update a case's status in Box (Approve / Resolve / Reject / Reopen from the dashboard). + if (req.method === 'POST' && reqUrl.pathname === '/api/box/set-status') { + const sup = AUTH_ENABLED ? await verifySupervisor(req, reqUrl) : null; + if (AUTH_ENABLED && !sup) { sendJson(res, 401, { ok: false, error: 'Supervisor login required.' }); return; } + const payload = await readJsonBody(req); + const caseId = String(payload.caseId || '').trim(); + const status = String(payload.status || '').trim(); + if (!caseId || !status) { sendJson(res, 400, { ok: false, error: 'caseId and status are required.' }); return; } + const by = (sup && (sup.email || sup.user_metadata?.email)) || payload.by || 'Supervisor'; + const canonical = canonicalStatus(status); + const decidedAt = nowIso(); + + let result; + try { + result = await boxService.setCaseStatusInBox({ caseId, status, by }); + } catch (e) { + sendJson(res, 500, { ok: false, error: e.message }); + return; + } + if (!result.ok) { + sendJson(res, 400, { ok: false, error: result.reason || 'update failed', ...result }); + return; + } + bustBoxCasesCache(); + + // Mirror the new status into the local store so the citizen tracker (the getCase + // fallback) and the audit log stay consistent with Box. + let storedCase = null; + try { + const { result: updated } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + if (idx < 0) return null; + const existing = store.cases[idx]; + const normalized = normalizeCaseRecord({ + ...existing, + status: canonical || existing.status, + statusUpdatedAt: decidedAt, + report: { + ...(existing.report || {}), + workflowStatus: canonical || existing.report?.workflowStatus, + workflowLastUpdatedAt: decidedAt, + }, + }, existing); + store.cases[idx] = normalized; + store.audit.push({ type: 'status_change', caseId, status: canonical, by, at: decidedAt }); + return normalized; + }); + storedCase = updated; + } catch (e) { + console.warn('[set-status] store mirror failed:', e.message); + } + + // Resolve a report view for the work order + citizen email; fall back to live Box + // metadata for cases that exist only in Box (e.g. seeded demo cases). + let report = storedCase?.report || null; + if (!report) { + try { + const boxCase = await boxService.searchCase(caseId); + if (boxCase) report = boxCase; + } catch (_) {} + } + report = report || {}; + + const artifacts = []; + // On approval (assigned), generate the formal Work Order from the template โ€” + // not just the email notification. + if (canonical === 'Assigned') { + const workOrderDoc = await generateCaseDocument({ + caseId, + docType: 'workorder', + metadata: buildWorkOrderMeta(report), + }).catch((error) => ({ error: error.message })); + artifacts.push({ + type: 'work_order_docx', + ...workOrderDoc, + fileName: workOrderDoc.fileName || `${caseId}_work_order.docx`, + }); + } + + // Notify the citizen of the new status via Box (skipped for anonymous / non-email + // contacts inside notifyCitizenViaBox). + let notification = null; + if (['Assigned', 'In Progress', 'Resolved', 'Rejected'].includes(canonical)) { + notification = await boxService.notifyCitizenViaBox({ + caseId, + caseRecord: { caseId, status: canonical, report }, + status: canonical, + citizenEmail: report.reporterContact, + }).catch((error) => ({ ok: false, error: error.message })); + } + + sendJson(res, 200, { ok: true, ...result, status: canonical, artifacts, notification }); + return; + } + + // Dispatch the closeout to the crew via Box Sign (WF2) โ€” reviewer enters the crew email per case. + if (req.method === 'POST' && reqUrl.pathname === '/api/box/dispatch-crew') { + const sup = AUTH_ENABLED ? await verifySupervisor(req, reqUrl) : null; + if (AUTH_ENABLED && !sup) { sendJson(res, 401, { ok: false, error: 'Supervisor login required.' }); return; } + const payload = await readJsonBody(req); + const caseId = String(payload.caseId || '').trim(); + const crewEmail = String(payload.crewEmail || '').trim(); + if (!caseId || !crewEmail) { sendJson(res, 400, { ok: false, error: 'caseId and crewEmail are required.' }); return; } + const supEmail = (sup && (sup.email || sup.user_metadata?.email)) || payload.supervisorEmail || ''; + try { + // 1) generate the WORK ORDER (repair assignment, photo embedded) for the crew to review + sign + const doc = await generateCaseDocument({ caseId, docType: 'workorder', metadata: payload.metadata || { caseId } }); + // 2) Box Sign request from the service account -> Box emails the crew (+ supervisor) to review & sign, + // with the closeout form link in the message. Signed doc returns to the case folder. + const signers = [{ email: crewEmail }]; + if (supEmail) signers.push({ email: supEmail }); + const formLink = String(payload.formLink || 'https://sednademo.app.box.com/f/bb3c86290ee94d7b8263bb9a33718e20'); + const sign = await boxService.createSignRequest({ + sourceFileId: doc.fileId, + parentFolderId: doc.folderId, + signers, + emailSubject: `Pothole Repair Work Order โ€” case ${caseId}`, + emailMessage: `You have been assigned pothole repair case ${caseId}. Please review and sign this work order. After completing the repair, fill out the closeout form: ${formLink}`, + }); + sendJson(res, 200, { ok: true, dispatchedTo: crewEmail, supervisor: supEmail || null, doc: doc.fileName, signRequestId: sign.signRequestId, status: sign.status }); + } catch (e) { + sendJson(res, 500, { ok: false, error: e.message }); + } + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/box/organize-case') { + const payload = await readJsonBody(req); + const result = await boxService.organizeCase(payload || {}); + sendJson(res, 200, { ok: true, result }); + return; + } + + if (req.method === 'GET' && reqUrl.pathname.startsWith('/api/box/files/') && reqUrl.pathname.endsWith('/content')) { + if (AUTH_ENABLED && !(await verifySupervisor(req, reqUrl))) { + sendJson(res, 401, { ok: false, error: 'Supervisor login required.' }); + return; + } + const fileId = decodeURIComponent( + reqUrl.pathname.slice('/api/box/files/'.length, -'/content'.length) + ); + if (!fileId) { + sendJson(res, 400, { ok: false, error: 'fileId is required.' }); + return; + } + + const result = await boxService.getFileContent(fileId); + sendBuffer(res, 200, result.body, { + 'Content-Type': result.contentType, + 'Cache-Control': 'private, max-age=60', + }, true); + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/box/search-case') { + const caseId = String(reqUrl.searchParams.get('caseId') || '').trim(); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required.' }); + return; + } + + const result = await boxService.searchCase(caseId); + sendJson(res, 200, { + ok: true, + found: !!result, + case: result, + }); + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/box/cases') { + if (AUTH_ENABLED && !(await verifySupervisor(req, reqUrl))) { + sendJson(res, 401, { ok: false, error: 'Supervisor login required.' }); + return; + } + try { + const cases = await getBoxCasesCached(); + sendJson(res, 200, { ok: true, count: cases.length, cases }); + } catch (error) { + sendJson(res, 200, { ok: false, error: error.message, cases: [] }); + } + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/gis/boundaries') { + // 1) Prefer a locally bundled ward-boundary GeoJSON (deterministic, no network). + // Drop a FeatureCollection at data/ward-boundaries.geojson for real ward mapping. + try { + const localGeoJson = await fs.readFile(GIS_BOUNDARIES_PATH, 'utf8'); + sendBuffer(res, 200, Buffer.from(localGeoJson, 'utf8'), { + 'Content-Type': 'application/geo+json; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + }, true); + return; + } catch { + // No local boundary file; fall through to the remote source. + } + + // 2) Try the configured remote boundary source, but never fail the request: on any + // error, return an empty FeatureCollection so the client falls back cleanly to + // reverse-geocode / region labels instead of surfacing a 500. + try { + const result = await boxService.fetchOfficialBoundaries(); + sendBuffer(res, 200, Buffer.from(result.body, 'utf8'), { + 'Content-Type': result.contentType, + 'Cache-Control': 'public, max-age=3600', + }, true); + } catch (error) { + console.warn('[server] GIS boundary source unavailable, serving empty FeatureCollection:', error.message); + const emptyFc = JSON.stringify({ type: 'FeatureCollection', features: [] }); + sendBuffer(res, 200, Buffer.from(emptyFc, 'utf8'), { + 'Content-Type': 'application/geo+json; charset=utf-8', + 'Cache-Control': 'no-store', + }, true); + } + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/duplicates') { + const lat = parseFloat(reqUrl.searchParams.get('lat')); + const lng = parseFloat(reqUrl.searchParams.get('lng')); + const radius = parseFloat(reqUrl.searchParams.get('radius')) || 80; + const exclude = reqUrl.searchParams.get('caseId') || null; + if (!db.isEnabled()) { + sendJson(res, 200, { ok: false, enabled: false, duplicates: [], note: 'Supabase not configured (set SUPABASE_DB_URL).' }); + return; + } + if (Number.isNaN(lat) || Number.isNaN(lng)) { + sendJson(res, 400, { ok: false, error: 'lat and lng are required.' }); + return; + } + try { + const duplicates = await db.findNearby(lat, lng, radius, exclude); + sendJson(res, 200, { ok: true, enabled: true, radiusM: radius, count: duplicates.length, duplicates }); + } catch (error) { + sendJson(res, 200, { ok: false, error: error.message, duplicates: [] }); + } + return; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/cases') { + const store = await readStore(); + sendJson(res, 200, { + ok: true, + count: store.cases.length, + cases: store.cases, + }); + return; + } + + if (req.method === 'GET' && reqUrl.pathname.startsWith('/api/cases/')) { + const caseId = decodeURIComponent(reqUrl.pathname.slice('/api/cases/'.length)); + const store = await readStore(); + const record = store.cases.find((entry) => String(entry.caseId) === caseId); + if (!record) { + sendJson(res, 404, { ok: false, error: `Case "${caseId}" was not found.` }); + return; + } + sendJson(res, 200, { ok: true, case: record }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/cases/upsert') { + const payload = stripPhotoDataUrl(await readJsonBody(req)); + const { result } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, payload.caseId); + const existing = idx >= 0 ? store.cases[idx] : null; + const normalized = normalizeCaseRecord(payload, existing); + if (idx >= 0) store.cases[idx] = normalized; + else store.cases.push(normalized); + store.audit.push({ + type: 'case_upsert', + caseId: normalized.caseId, + at: nowIso(), + }); + return normalized; + }); + await mirrorCaseToDb(result); + sendJson(res, 200, { ok: true, case: result }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/cases/bulk-upsert') { + const payload = stripPhotoDataUrl(await readJsonBody(req)); + const incomingCases = Array.isArray(payload.cases) ? payload.cases : []; + const { result } = await queueStoreMutation(async (store) => { + const updated = []; + for (const item of incomingCases) { + if (!isPlainObject(item) || !item.caseId) continue; + const idx = findCaseIndex(store, item.caseId); + const existing = idx >= 0 ? store.cases[idx] : null; + const normalized = normalizeCaseRecord(item, existing); + if (idx >= 0) store.cases[idx] = normalized; + else store.cases.push(normalized); + updated.push(normalized); + } + store.audit.push({ + type: 'case_bulk_upsert', + count: updated.length, + at: nowIso(), + }); + return updated; + }); + for (const rec of result) await mirrorCaseToDb(rec); + sendJson(res, 200, { ok: true, count: result.length, cases: result }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname.startsWith('/api/cases/') && reqUrl.pathname.endsWith('/patch')) { + const caseId = decodeURIComponent(reqUrl.pathname.slice('/api/cases/'.length, -'/patch'.length)); + const payload = stripPhotoDataUrl(await readJsonBody(req)); + const { result } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + if (idx < 0) { + throw new Error(`Case "${caseId}" was not found.`); + } + const merged = normalizeCaseRecord({ + ...store.cases[idx], + ...payload, + caseId, + }, store.cases[idx]); + store.cases[idx] = merged; + store.audit.push({ + type: 'case_patch', + caseId, + at: nowIso(), + }); + return merged; + }); + + if (result.status === 'Resolved') { + boxService.generateAndUploadAuditReport({ caseId, caseRecord: result }) + .then((uploadRes) => console.log(`[AuditReport] Successfully uploaded audit report for ${caseId}:`, uploadRes.fileName)) + .catch((err) => console.warn(`[AuditReport] Failed to upload audit report for ${caseId}:`, err.message)); + } + + sendJson(res, 200, { ok: true, case: result }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/workflow/supervisor-decision') { + const payload = stripPhotoDataUrl(await readJsonBody(req)); + const caseId = String(payload.caseId || '').trim(); + const decisionRaw = String( + payload.decision + || (payload.approved === true ? 'approve' : (payload.approved === false ? 'reject' : '')) + ).trim().toLowerCase(); + const approved = ['approve', 'approved', 'yes'].includes(decisionRaw); + const rejected = ['reject', 'rejected', 'no'].includes(decisionRaw); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required.' }); + return; + } + if (!approved && !rejected) { + sendJson(res, 400, { ok: false, error: 'decision must be approve or reject.' }); + return; + } + + const reviewer = String(payload.reviewer || payload.by || '').trim() || 'Supervisor'; + const reason = String(payload.reason || '').trim(); + const potholeSize = String(payload.potholeSize || payload.size || '').trim(); + const decidedAt = nowIso(); + + const { result: baseCase } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + if (idx < 0) { + throw new Error(`Case "${caseId}" was not found.`); + } + + const existing = store.cases[idx]; + const existingReport = existing.report || {}; + const normalized = normalizeCaseRecord({ + ...existing, + status: approved ? 'Under Review' : 'Rejected', + statusUpdatedAt: decidedAt, + report: { + ...existingReport, + supervisor: { + ...(existingReport.supervisor || {}), + verified: true, + isPothole: approved, + size: potholeSize || existingReport.potholeSize || existingReport.aiResult || existingReport.aiPrediction || 'Medium', + by: reviewer, + reason, + at: decidedAt, + }, + potholeSize: potholeSize || existingReport.potholeSize || existingReport.aiResult || existingReport.aiPrediction || '', + aiReviewStatus: approved ? 'manual_confirmed' : 'manual_rejected', + workflowStage: approved ? 'supervisor_review' : 'rejected', + workflowStatus: approved ? 'Under Review' : 'Rejected', + workflowQueue: approved ? 'Dispatch Planning' : 'Closed', + workflowNextAction: approved + ? 'Generate authority notice, create work order, and dispatch crew' + : 'No further action. Rejected during supervisor review.', + workflowNeedsManualReview: 'false', + workflowLastUpdatedAt: decidedAt, + reviewerNotes: reason || existingReport.reviewerNotes || '', + supervisorDecision: approved ? 'Approve' : 'Reject', + }, + }, existing); + + store.cases[idx] = normalized; + store.audit.push({ + type: 'supervisor_decision', + caseId, + decision: approved ? 'approved' : 'rejected', + reviewer, + at: decidedAt, + }); + return normalized; + }); + + let currentCase = baseCase; + const artifacts = []; + + if (approved) { + const authorityRoutingConfig = await readAuthorityRouting(); + const authorityRoute = resolveAuthorityRoute(currentCase, authorityRoutingConfig); + const noticeArtifact = buildAuthorityNoticeArtifact(currentCase, authorityRoute); + const workOrderArtifact = buildWorkOrderArtifact(currentCase, authorityRoute); + + const noticeUpload = await boxService.uploadCaseArtifact({ + caseId, + caseRecord: currentCase, + fileName: noticeArtifact.fileName, + content: noticeArtifact.content, + contentType: noticeArtifact.contentType, + }).catch((error) => ({ error: error.message })); + const workOrderUpload = await boxService.uploadCaseArtifact({ + caseId, + caseRecord: currentCase, + fileName: workOrderArtifact.fileName, + content: workOrderArtifact.content, + contentType: workOrderArtifact.contentType, + }).catch((error) => ({ error: error.message })); + + artifacts.push( + { type: 'authority_notice', ...noticeUpload, fileName: noticeArtifact.fileName }, + { type: 'work_order', ...workOrderUpload, fileName: workOrderArtifact.fileName } + ); + + // Also generate a formatted Work Order (.docx, photo embedded) for the crew. + const wor = currentCase.report || {}; + const workMeta = { + address: wor.address, + ward: wor.ward, + district: wor.district, + maintenanceZone: wor.maintenanceZone, + latitude: wor.location?.lat ?? wor.latitude, + longitude: wor.location?.lng ?? wor.longitude, + severityScore: wor.enrichment?.severityScore ?? wor.severityScore ?? wor.severityScore0100, + severityLevel: wor.enrichment?.severityLevel ?? wor.severityLevel, + potholeSize: wor.potholeSize || wor.supervisor?.size || wor.aiResult, + weatherRisk: wor.weatherRisk, + crewName: wor.dispatchPlan?.crewName || wor.crewName || wor.assignedTo, + targetDate: wor.dispatchPlan?.targetDate || wor.targetDate, + photoFileId: wor.photoFileId, + }; + const workOrderDoc = await generateCaseDocument({ caseId, docType: 'workorder', metadata: workMeta }) + .catch((error) => ({ error: error.message })); + artifacts.push({ type: 'work_order_docx', ...workOrderDoc, fileName: workOrderDoc.fileName || `${caseId}_work_order.docx` }); + + const { result: artifactCase } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + const existing = idx >= 0 ? store.cases[idx] : currentCase; + const existingReport = existing.report || {}; + const normalized = normalizeCaseRecord({ + ...existing, + report: { + ...existingReport, + authorityRouting: authorityRoute, + authorityNotification: { + ...noticeArtifact.payload, + artifactFileName: noticeArtifact.fileName, + artifactFileId: noticeUpload.fileId || '', + skipped: !!noticeUpload.skipped, + error: noticeUpload.error || '', + }, + workOrder: { + ...workOrderArtifact.payload, + artifactFileName: workOrderArtifact.fileName, + artifactFileId: workOrderUpload.fileId || '', + skipped: !!workOrderUpload.skipped, + error: workOrderUpload.error || '', + }, + workOrderId: workOrderArtifact.payload.workOrderId, + }, + }, existing); + if (idx >= 0) store.cases[idx] = normalized; + else store.cases.push(normalized); + return normalized; + }); + currentCase = artifactCase; + } else { + const rejectionArtifact = buildRejectionArtifact(currentCase); + const rejectionUpload = await boxService.uploadCaseArtifact({ + caseId, + caseRecord: currentCase, + fileName: rejectionArtifact.fileName, + content: rejectionArtifact.content, + contentType: rejectionArtifact.contentType, + }).catch((error) => ({ error: error.message })); + + artifacts.push({ type: 'rejection_note', ...rejectionUpload, fileName: rejectionArtifact.fileName }); + + const { result: artifactCase } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + const existing = idx >= 0 ? store.cases[idx] : currentCase; + const existingReport = existing.report || {}; + const normalized = normalizeCaseRecord({ + ...existing, + report: { + ...existingReport, + rejectionNote: { + fileName: rejectionArtifact.fileName, + fileId: rejectionUpload.fileId || '', + skipped: !!rejectionUpload.skipped, + error: rejectionUpload.error || '', + }, + }, + }, existing); + if (idx >= 0) store.cases[idx] = normalized; + else store.cases.push(normalized); + return normalized; + }); + currentCase = artifactCase; + } + + sendJson(res, 200, { + ok: true, + case: currentCase, + artifacts, + }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/workflow/dispatch-plan') { + const payload = stripPhotoDataUrl(await readJsonBody(req)); + const crews = Array.isArray(payload.crews) ? payload.crews : []; + const selectedWards = uniqueStrings(Array.isArray(payload.selectedWards) ? payload.selectedWards : []); + const numCrewsValue = Number(payload.numCrews); + const numCrews = Number.isFinite(numCrewsValue) && numCrewsValue > 0 + ? Math.floor(numCrewsValue) + : Math.max(1, crews.length); + const requestedSentAt = String(payload.sentAt || '').trim(); + const dispatchedAt = Number.isFinite(Date.parse(requestedSentAt)) + ? new Date(requestedSentAt).toISOString() + : nowIso(); + const planId = String(payload.planId || '').trim() + || `DSP-${dispatchedAt.slice(0, 10).replace(/-/g, '')}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`; + + const routeAssignments = []; + crews.forEach((crew, crewIndex) => { + const route = Array.isArray(crew?.route) ? crew.route : []; + const crewIdxValue = Number(crew?.crewIdx); + const crewIdx = Number.isFinite(crewIdxValue) ? crewIdxValue : crewIndex; + const crewName = String(crew?.crewName || `Crew ${crewIdx + 1}`).trim() || `Crew ${crewIdx + 1}`; + const crewColor = String(crew?.crewColor || '').trim(); + const totalStops = route.length; + + route.forEach((stop, stopIndex) => { + const caseId = String(stop?.caseId || '').trim(); + if (!caseId) return; + + const stopIndexValue = Number(stop?.stopIndex); + routeAssignments.push({ + caseId, + crewIdx, + crewName, + crewColor, + totalStops, + stopIndex: Number.isFinite(stopIndexValue) && stopIndexValue > 0 ? Math.floor(stopIndexValue) : (stopIndex + 1), + }); + }); + }); + + if (!routeAssignments.length) { + sendJson(res, 400, { ok: false, error: 'At least one routed case is required.' }); + return; + } + + const assignmentMap = new Map(); + routeAssignments.forEach((assignment) => { + if (!assignmentMap.has(assignment.caseId)) { + assignmentMap.set(assignment.caseId, assignment); + } + }); + + const skippedCaseIds = []; + const updatedCaseIds = []; + const { result: dispatchedCases } = await queueStoreMutation(async (store) => { + const updatedCases = []; + + for (const [caseId, assignment] of assignmentMap.entries()) { + const idx = findCaseIndex(store, caseId); + if (idx < 0) { + skippedCaseIds.push(caseId); + continue; + } + + const existing = store.cases[idx]; + const existingReport = existing.report || {}; + const normalized = normalizeCaseRecord({ + ...existing, + status: 'Assigned', + statusUpdatedAt: dispatchedAt, + report: { + ...existingReport, + dispatchPlan: { + ...(existingReport.dispatchPlan || {}), + planId, + sentAt: dispatchedAt, + crewIdx: assignment.crewIdx, + crewName: assignment.crewName, + crewColor: assignment.crewColor, + stopIndex: assignment.stopIndex, + totalStops: assignment.totalStops, + selectedWards: selectedWards.slice(), + numCrews, + }, + assignedTo: assignment.crewName, + crewName: assignment.crewName, + dispatchedAt, + workflowStage: 'crew_dispatch', + workflowStatus: 'Assigned', + workflowQueue: 'Field Operations', + workflowNextAction: 'Crew should repair the pothole and submit closeout proof.', + workflowLastUpdatedAt: dispatchedAt, + }, + }, existing); + + store.cases[idx] = normalized; + updatedCases.push(normalized); + updatedCaseIds.push(caseId); + } + + store.audit.push({ + type: 'dispatch_plan', + planId, + at: dispatchedAt, + totalStops: routeAssignments.length, + persistedCases: updatedCases.length, + skippedCases: skippedCaseIds.length, + }); + + return updatedCases; + }); + + const dispatchArtifacts = await Promise.all(dispatchedCases.map(async (caseRecord) => { + const artifact = buildDispatchAssignmentArtifact(caseRecord); + try { + const upload = await boxService.uploadCaseArtifact({ + caseId: caseRecord.caseId, + caseRecord, + fileName: artifact.fileName, + content: artifact.content, + contentType: artifact.contentType, + }); + return { + type: 'dispatch_assignment', + caseId: caseRecord.caseId, + fileName: artifact.fileName, + ...upload, + }; + } catch (error) { + return { + type: 'dispatch_assignment', + caseId: caseRecord.caseId, + fileName: artifact.fileName, + error: error.message, + }; + } + })); + + let finalCases = dispatchedCases; + if (updatedCaseIds.length) { + const artifactMap = new Map(dispatchArtifacts.map((artifact) => [artifact.caseId, artifact])); + const { result: artifactCases } = await queueStoreMutation(async (store) => { + const updatedCases = []; + + for (const caseId of updatedCaseIds) { + const idx = findCaseIndex(store, caseId); + if (idx < 0) continue; + + const existing = store.cases[idx]; + const existingReport = existing.report || {}; + const artifact = artifactMap.get(caseId); + const normalized = normalizeCaseRecord({ + ...existing, + report: { + ...existingReport, + dispatchArtifact: { + fileName: artifact?.fileName || `${caseId}_dispatch_assignment.md`, + fileId: artifact?.fileId || '', + skipped: !!artifact?.skipped, + error: artifact?.error || '', + uploadedAt: dispatchedAt, + }, + }, + }, existing); + + store.cases[idx] = normalized; + updatedCases.push(normalized); + } + + return updatedCases; + }); + finalCases = artifactCases; + } + + sendJson(res, 200, { + ok: true, + planId, + sentAt: dispatchedAt, + cases: finalCases, + skippedCaseIds, + artifacts: dispatchArtifacts, + }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/workflow/crew-closeout') { + const payload = stripPhotoDataUrl(await readJsonBody(req)); + const caseId = String(payload.caseId || '').trim(); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required.' }); + return; + } + + const crewClosedAt = nowIso(); + const { result: baseCase } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + if (idx < 0) { + throw new Error(`Case "${caseId}" was not found.`); + } + + const existing = store.cases[idx]; + const existingReport = existing.report || {}; + const dispatchPlan = existingReport.dispatchPlan || {}; + const normalized = normalizeCaseRecord({ + ...existing, + status: 'Resolved', + statusUpdatedAt: crewClosedAt, + report: { + ...existingReport, + crewName: String(payload.crewName || dispatchPlan.crewName || existingReport.crewName || '').trim(), + assignedTo: String(payload.crewName || dispatchPlan.crewName || existingReport.assignedTo || '').trim(), + repairMethod: String(payload.repairMethod || '').trim(), + materialsUsed: String(payload.materialsUsed || '').trim(), + laborHours: String(payload.laborHours || '').trim(), + laborRate: String(payload.laborRate || '').trim(), + materialsCost: String(payload.materialsCost || '').trim(), + totalCost: String(payload.totalCost || '').trim(), + crewSignature: String(payload.crewSignature || '').trim(), + resolutionNotes: String(payload.resolutionNotes || existingReport.resolutionNotes || '').trim(), + afterPhotoFileId: String(payload.afterPhotoFileId || existingReport.afterPhotoFileId || '').trim(), + afterPhotoFileName: String(payload.afterPhotoFileName || existingReport.afterPhotoFileName || '').trim(), + resolvedAt: crewClosedAt, + workflowStage: 'resolved', + workflowStatus: 'Resolved', + workflowQueue: 'Completed', + workflowNextAction: 'Case closed. Final audit artifacts generated.', + workflowLastUpdatedAt: crewClosedAt, + }, + }, existing); + + store.cases[idx] = normalized; + store.audit.push({ + type: 'crew_closeout', + caseId, + crewName: normalized.report.crewName || '', + at: crewClosedAt, + }); + return normalized; + }); + + // Backfill the record with the GIS-extracted data (address, coords, ward, district, + // zone, reporter contact) from Box metadata + enrichment, so the report AND the DB + // reflect everything we extracted โ€” even when the flat report fields are thin or hold + // "Ward-Unknown"/"Unassigned" enrichment fallbacks. + let boxMeta = null; + let sidecar = null; + try { boxMeta = await boxService.searchCase(caseId); } catch (_) {} + try { sidecar = await boxService.getCaseSubmissionData(caseId); } catch (_) {} + const { result: filledCase } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + if (idx < 0) return baseCase; + const r = store.cases[idx].report || {}; + const d = (r.enrichment && r.enrichment.district) || {}; + const sc = sidecar || {}; + const clean = (v) => { + const s = String(v == null ? '' : v).trim(); + return (!s || /^ward[-\s]?unknown$/i.test(s) || /^unassigned$/i.test(s) || /^pending/i.test(s)) ? '' : s; + }; + const pick = (...vals) => { for (const v of vals) { const c = clean(v); if (c) return c; } return ''; }; + const reporterName = pick(r.reporterName, sc.reporterName); + const reporterContact = pick(r.reporterContact, sc.reporterContact, boxMeta && boxMeta.reporterContact); + const filled = { + address: pick(r.address, sc.address, boxMeta && boxMeta.address), + ward: pick(r.ward, boxMeta && boxMeta.ward, d.ward, sc.ward), + district: pick(r.district, boxMeta && boxMeta.district, d.district), + maintenanceZone: pick(r.maintenanceZone, boxMeta && boxMeta.maintenanceZone, d.zone, d.maintenanceZone), + location: (r.location && r.location.lat != null) ? r.location : (sc.location && sc.location.lat != null ? sc.location : (boxMeta && boxMeta.location) || r.location || null), + reporterName, + reporterContact, + }; + // Anonymous only if the sidecar says so AND there's no name/contact on record. + const submittedAnon = sc.anonymous !== undefined ? !!sc.anonymous : !!r.anonymous; + filled.anonymous = (reporterName || reporterContact) ? false : submittedAnon; + store.cases[idx] = normalizeCaseRecord( + { ...store.cases[idx], report: { ...r, ...filled } }, + store.cases[idx] + ); + return store.cases[idx]; + }); + + await mirrorCaseToDb(filledCase); + + // Flip the case folder's Box metadata to Resolved so the Command Center (which + // reads status from Box metadata) reflects the closeout in real time. + boxService.markCaseResolvedInBox({ + caseId, + caseRecord: filledCase, + resolvedAt: crewClosedAt, + resolvedBy: filledCase.report?.crewSignature || filledCase.report?.crewName || '', + }) + .then((r) => { + bustBoxCasesCache(); + console.log(`[BoxStatus] ${caseId} -> resolved in Box:`, r.skipped ? `skipped (${r.reason})` : r.folderId); + }) + .catch((err) => console.warn(`[BoxStatus] resolve metadata failed for ${caseId}:`, err.message)); + + // Primary before/after report: a PDF with both photos embedded + all data. + // Falls back to .docx, then a markdown summary, if generation fails. + let beforeAfterUpload; + let beforeAfterFileName; + try { + beforeAfterUpload = await generateBeforeAfterReport({ caseId, caseRecord: filledCase }); + beforeAfterFileName = beforeAfterUpload.fileName; + } catch (docErr) { + console.warn('[closeout] before/after docx failed, falling back to markdown:', docErr.message); + const beforeAfterArtifact = buildBeforeAfterArtifact(filledCase); + beforeAfterFileName = beforeAfterArtifact.fileName; + beforeAfterUpload = await boxService.uploadCloseoutReport({ + caseId, + fileName: beforeAfterArtifact.fileName, + content: beforeAfterArtifact.content, + contentType: beforeAfterArtifact.contentType, + }).catch((error) => ({ error: error.message })); + } + + let auditUpload = null; + try { + auditUpload = await boxService.generateAndUploadAuditReport({ caseId, caseRecord: filledCase }); + } catch (error) { + auditUpload = { error: error.message }; + } + + // Box-native notification: give each recipient one-time access to the report + // workspace (notify=true). Recipients = configured defaults + any typed into the + // form. No-op when there are none or Box isn't configured. + const defaultRecipients = await getCloseoutDefaultRecipients(); + const formRecipients = parseRecipients(payload.notifyEmail); + const recipients = dedupeEmails([...defaultRecipients, ...formRecipients]); + + const notifications = []; + for (const email of recipients) { + const r = await boxService.notifyCloseoutViaBox({ + caseId, + caseRecord: baseCase, + reportFileId: beforeAfterUpload.fileId || '', + recipientEmail: email, + }).catch((error) => ({ ok: false, emailed: false, to: email, error: error.message })); + notifications.push({ to: email, ...r }); + } + const reportSharedLink = notifications.find((n) => n.sharedLink)?.sharedLink || ''; + + const { result: finalCase } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + const existing = idx >= 0 ? store.cases[idx] : baseCase; + const existingReport = existing.report || {}; + const normalized = normalizeCaseRecord({ + ...existing, + report: { + ...existingReport, + beforeAfterReport: { + fileName: beforeAfterFileName, + fileId: beforeAfterUpload.fileId || '', + embeddedImages: beforeAfterUpload.embeddedImages || 0, + skipped: !!beforeAfterUpload.skipped, + error: beforeAfterUpload.error || '', + }, + auditReport: { + fileName: auditUpload?.fileName || `${caseId}_audit_report.md`, + fileId: auditUpload?.fileId || '', + skipped: !!auditUpload?.skipped, + error: auditUpload?.error || '', + }, + closeoutNotification: { + sharedLink: reportSharedLink, + at: crewClosedAt, + recipients: notifications.map((n) => ({ + to: n.to, + emailed: !!n.emailed, + firstTime: !!n.firstTime, + alreadyHasAccess: !!n.alreadyHasAccess, + error: n.error || '', + })), + }, + }, + }, existing); + if (idx >= 0) store.cases[idx] = normalized; + else store.cases.push(normalized); + return normalized; + }); + + sendJson(res, 200, { + ok: true, + case: finalCase, + notifications, + sharedLink: reportSharedLink, + artifacts: [ + { type: 'before_after_report', ...beforeAfterUpload, fileName: beforeAfterFileName }, + { type: 'audit_report', ...(auditUpload || {}) }, + ], + }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/workflow/resolve-case') { + const payload = stripPhotoDataUrl(await readJsonBody(req)); + const caseId = String(payload.caseId || '').trim(); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required.' }); + return; + } + + const resolvedAt = nowIso(); + const verifier = String(payload.verifiedBy || payload.resolvedBy || '').trim() || 'Supervisor'; + const { result } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + if (idx < 0) { + throw new Error(`Case "${caseId}" was not found.`); + } + const existing = store.cases[idx]; + const existingReport = existing.report || {}; + const normalized = normalizeCaseRecord({ + ...existing, + status: 'Resolved', + statusUpdatedAt: resolvedAt, + report: { + ...existingReport, + resolutionNotes: String(payload.resolutionNotes || existingReport.resolutionNotes || '').trim(), + resolvedAt, + closeoutVerifiedBy: verifier, + closeoutVerifiedAt: resolvedAt, + workflowStage: 'resolved', + workflowStatus: 'Resolved', + workflowQueue: 'Completed', + workflowNextAction: 'Case closed.', + workflowLastUpdatedAt: resolvedAt, + }, + }, existing); + store.cases[idx] = normalized; + store.audit.push({ + type: 'case_resolved', + caseId, + by: verifier, + at: resolvedAt, + }); + return normalized; + }); + + await mirrorCaseToDb(result); + + let auditUpload = null; + try { + auditUpload = await boxService.generateAndUploadAuditReport({ caseId, caseRecord: result }); + } catch (error) { + auditUpload = { error: error.message }; + } + + let boxStatusUpdate = null; + try { + boxStatusUpdate = await boxService.markCaseResolvedInBox({ + caseId, + caseRecord: result, + resolvedAt, + resolvedBy: verifier, + }); + bustBoxCasesCache(); + } catch (error) { + boxStatusUpdate = { error: error.message }; + } + + sendJson(res, 200, { + ok: true, + case: result, + artifacts: [ + { type: 'audit_report', ...(auditUpload || {}) }, + { type: 'box_status', ...(boxStatusUpdate || {}) }, + ], + }); + return; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/box/automate-update') { + if (!validateBoxSecret(reqUrl, req)) { + sendJson(res, 401, { ok: false, error: 'Invalid Box Automate secret.' }); + return; + } + + const payload = await readJsonBody(req); + const caseId = String(payload.caseId || payload.report?.caseId || '').trim(); + if (!caseId) { + sendJson(res, 400, { ok: false, error: 'caseId is required for Box Automate updates.' }); + return; + } + + const { result } = await queueStoreMutation(async (store) => { + const idx = findCaseIndex(store, caseId); + const existing = idx >= 0 ? store.cases[idx] : null; + const patch = buildBoxUpdatePatch(payload, existing); + const normalized = normalizeCaseRecord({ + ...(existing || { caseId, report: {} }), + ...patch, + }, existing); + if (idx >= 0) store.cases[idx] = normalized; + else store.cases.push(normalized); + store.audit.push({ + type: 'box_automate_update', + caseId, + at: nowIso(), + workflowStage: patch.report.workflowStage || '', + workflowStatus: patch.report.workflowStatus || '', + }); + return normalized; + }); + + await mirrorCaseToDb(result); + + if (result.status === 'Resolved') { + boxService.generateAndUploadAuditReport({ caseId, caseRecord: result }) + .then((uploadRes) => console.log(`[AuditReport] Successfully uploaded audit report via Box Automate callback for ${caseId}:`, uploadRes.fileName)) + .catch((err) => console.warn(`[AuditReport] Failed to upload audit report via Box Automate callback for ${caseId}:`, err.message)); + + boxService.markCaseResolvedInBox({ + caseId, + caseRecord: result, + resolvedAt: result.report?.resolvedAt || nowIso(), + resolvedBy: result.report?.closeoutVerifiedBy || '', + }) + .then((r) => { + bustBoxCasesCache(); + console.log(`[BoxStatus] Case ${caseId} metadata set to resolved via Box Automate callback:`, r.skipped ? `skipped (${r.reason})` : r.folderId); + }) + .catch((err) => console.warn(`[BoxStatus] Failed to set resolved metadata via Box Automate callback for ${caseId}:`, err.message)); + } + + sendJson(res, 200, { ok: true, case: result }); + return; + } + + sendJson(res, 404, { ok: false, error: 'API route not found.' }); +} + +async function serveStaticFile(reqUrl, res) { + let pathname = decodeURIComponent(reqUrl.pathname); + if (pathname === '/') pathname = '/index.html'; + + const requestedPath = path.normalize(path.join(ROOT_DIR, pathname)); + if (!requestedPath.startsWith(ROOT_DIR)) { + sendText(res, 403, 'Forbidden'); + return; + } + + let filePath = requestedPath; + try { + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + filePath = path.join(filePath, 'index.html'); + } + } catch { + sendText(res, 404, 'Not found'); + return; + } + + try { + const content = await fs.readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, { + 'Content-Type': contentType, + 'Content-Length': content.length, + 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=300', + }); + res.end(content); + } catch (err) { + sendText(res, 500, `Static file error: ${err.message}`); + } +} + +const server = http.createServer(async (req, res) => { + try { + const reqUrl = new URL(req.url, `http://${req.headers.host || `localhost:${PORT}`}`); + if (reqUrl.pathname.startsWith('/api/')) { + await handleApiRequest(req, res, reqUrl); + return; + } + await serveStaticFile(reqUrl, res); + } catch (err) { + console.error('[server] request failed:', err); + if (!res.headersSent) { + sendJson(res, 500, { ok: false, error: err.message || 'Internal server error.' }); + } else { + res.end(); + } + } +}); + +server.listen(PORT, HOST, () => { + console.log(`[server] pothole workflow server listening on http://localhost:${PORT}`); + console.log(`[server] legacy Box Automate callback configured: ${BOX_AUTOMATE_SECRET ? 'yes' : 'no'}`); + // Pre-warm the YOLO classifier (load model once) so the first detection is real-time. + ensureYoloWorker() + .then(() => console.log('[server] pothole classifier ready (model warmed)')) + .catch((e) => console.warn('[server] classifier warm-up failed (will retry on demand):', e.message)); +}); + diff --git a/settings.html b/settings.html new file mode 100644 index 0000000000000000000000000000000000000000..e15a5db1c043fa22218cc4c512a0eab5df14c03b --- /dev/null +++ b/settings.html @@ -0,0 +1,437 @@ + + + + + + Portal Settings - Road Reporting + + + + + + + + + +
+
+ +

Administrator Access

+

Enter your 4-digit PIN to access portal settings

+
+ +
+
+ + โ† Back to Portal +
+
+ + + +
+ +
+ + + + + +
+ + + + + + + + + diff --git a/settings.js b/settings.js new file mode 100644 index 0000000000000000000000000000000000000000..7f33be2c4591888fafb80cb4703fb811b24c0c75 --- /dev/null +++ b/settings.js @@ -0,0 +1,690 @@ +'use strict'; + +const DEFAULT_PIN = '1234'; + +const STORAGE_KEYS = { + city: 'city_name', + dept: 'dept_name', + pin: 'portal_pin', + anonymous: 'allow_anonymous', + tmUrl: 'tm_model_url', + gisGeoJsonUrl: 'gis_geojson_url', + googleMapsJsKey: 'google_maps_js_api_key', + gisWardProp: 'gis_ward_prop', + gisDistrictProp: 'gis_district_prop', + gisZoneProp: 'gis_zone_prop', + weatherKey: 'weather_api_key', +}; + +const DEFAULT_SETTINGS = { + [STORAGE_KEYS.city]: '', + [STORAGE_KEYS.dept]: '', + [STORAGE_KEYS.pin]: DEFAULT_PIN, + [STORAGE_KEYS.anonymous]: 'false', + [STORAGE_KEYS.tmUrl]: 'https://teachablemachine.withgoogle.com/models/3HQTi9DMo/', + [STORAGE_KEYS.gisGeoJsonUrl]: '', + [STORAGE_KEYS.googleMapsJsKey]: '', + [STORAGE_KEYS.gisWardProp]: 'ward', + [STORAGE_KEYS.gisDistrictProp]: 'district', + [STORAGE_KEYS.gisZoneProp]: 'zone', + [STORAGE_KEYS.weatherKey]: '', +}; + +const BOX_FIELD_IDS = { + authMode: 'field-auth-mode', + accessToken: 'field-access-token', + refreshToken: 'field-refresh-token', + clientId: 'field-client-id', + clientSecret: 'field-client-secret', + subjectType: 'field-subject-type', + subjectId: 'field-subject-id', + intakeFolderId: 'field-intake-folder-id', + caseRootFolderId: 'field-case-root-folder-id', + metadataScope: 'field-metadata-scope', + metadataTemplateKey: 'field-metadata-template-key', +}; + +function normalizeString(value) { + return String(value ?? '').trim(); +} + +function lsGet(key, fallback = '') { + const value = localStorage.getItem(key); + return value !== null ? value : fallback; +} + +function lsSet(key, value) { + localStorage.setItem(key, String(value)); +} + +function getFirstElement(...ids) { + for (const id of ids) { + const el = document.getElementById(id); + if (el) return el; + } + return null; +} + +function showToast(message, type = 'success', duration = 3000) { + const toast = document.getElementById('toast'); + if (!toast) return; + + toast.textContent = message; + toast.className = 'toast'; + toast.classList.add(`toast--${type}`, 'toast--visible'); + + clearTimeout(toast._hideTimer); + toast._hideTimer = setTimeout(() => { + toast.classList.remove('toast--visible'); + }, duration); +} + +async function copyText(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', 'true'); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + document.execCommand('copy'); + document.body.removeChild(area); +} + +async function requestJson(path, options = {}) { + const response = await fetch(path, { + method: options.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + ...(options.body !== undefined ? { body: options.body } : {}), + }); + + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + throw new Error(payload?.error || payload?.message || `Request failed with HTTP ${response.status}`); + } + + return payload; +} + +function getBoxFormData() { + return { + authMode: normalizeString(document.getElementById(BOX_FIELD_IDS.authMode)?.value || 'oauth_refresh'), + accessToken: normalizeString(document.getElementById(BOX_FIELD_IDS.accessToken)?.value || ''), + refreshToken: normalizeString(document.getElementById(BOX_FIELD_IDS.refreshToken)?.value || ''), + clientId: normalizeString(document.getElementById(BOX_FIELD_IDS.clientId)?.value || ''), + clientSecret: normalizeString(document.getElementById(BOX_FIELD_IDS.clientSecret)?.value || ''), + subjectType: normalizeString(document.getElementById(BOX_FIELD_IDS.subjectType)?.value || 'enterprise'), + subjectId: normalizeString(document.getElementById(BOX_FIELD_IDS.subjectId)?.value || ''), + intakeFolderId: normalizeString(document.getElementById(BOX_FIELD_IDS.intakeFolderId)?.value || ''), + caseRootFolderId: normalizeString(document.getElementById(BOX_FIELD_IDS.caseRootFolderId)?.value || ''), + metadataScope: normalizeString(document.getElementById(BOX_FIELD_IDS.metadataScope)?.value || ''), + metadataTemplateKey: normalizeString(document.getElementById(BOX_FIELD_IDS.metadataTemplateKey)?.value || ''), + }; +} + +function setSecretPlaceholder(inputId, preview, label) { + const input = document.getElementById(inputId); + if (!input) return; + if (preview) { + input.placeholder = `Saved on backend (${preview}). Enter a new ${label} to replace it.`; + } else { + input.placeholder = label; + } +} + +function setBoxSecretStatus(config = {}, status = {}) { + const el = document.getElementById('box-secret-status'); + if (!el) return; + + const notes = []; + if (config.hasAccessToken) notes.push(`access token ${config.accessTokenPreview || '(saved)'}`); + if (config.hasRefreshToken) notes.push(`refresh token ${config.refreshTokenPreview || '(saved)'}`); + if (config.hasClientSecret) notes.push(`client secret ${config.clientSecretPreview || '(saved)'}`); + + if (!notes.length) { + el.textContent = 'No Box credentials saved on the backend yet.'; + return; + } + + const mode = status.authMode ? `Mode: ${status.authMode}. ` : ''; + el.textContent = `${mode}Backend secrets saved: ${notes.join(' | ')}`; +} + +function applyBoxConfigToForm(config = {}, status = {}) { + const fieldMap = { + [BOX_FIELD_IDS.authMode]: config.authMode || status.authMode || 'oauth_refresh', + [BOX_FIELD_IDS.clientId]: config.clientId || '', + [BOX_FIELD_IDS.subjectType]: config.subjectType || 'enterprise', + [BOX_FIELD_IDS.subjectId]: config.subjectId || '', + [BOX_FIELD_IDS.intakeFolderId]: config.intakeFolderId || '', + [BOX_FIELD_IDS.caseRootFolderId]: config.caseRootFolderId || '', + [BOX_FIELD_IDS.metadataScope]: config.metadataScope || '', + [BOX_FIELD_IDS.metadataTemplateKey]: config.metadataTemplateKey || '', + }; + + for (const [id, value] of Object.entries(fieldMap)) { + const el = document.getElementById(id); + if (el) el.value = value; + } + + // Secret fields stay blank after load so they remain server-side only. + ['accessToken', 'refreshToken', 'clientSecret'].forEach((field) => { + const el = document.getElementById(BOX_FIELD_IDS[field]); + if (el) el.value = ''; + }); + + setSecretPlaceholder(BOX_FIELD_IDS.accessToken, config.accessTokenPreview, 'access token'); + setSecretPlaceholder(BOX_FIELD_IDS.refreshToken, config.refreshTokenPreview, 'refresh token'); + setSecretPlaceholder(BOX_FIELD_IDS.clientSecret, config.clientSecretPreview, 'client secret'); + setBoxSecretStatus(config, status); + renderBoxWorkflowBlueprint(); +} + +function normalizeBoxFieldHints() { + const accessRow = document.querySelector(`label[for="${BOX_FIELD_IDS.accessToken}"]`)?.closest('.settings-row'); + const intakeRow = document.querySelector(`label[for="${BOX_FIELD_IDS.intakeFolderId}"]`)?.closest('.settings-row'); + const accessHint = accessRow?.querySelector('.field-hint'); + const intakeHint = intakeRow?.querySelector('.field-hint'); + + if (accessHint) { + accessHint.textContent = 'Temporary fallback only. Prefer OAuth refresh tokens or client credentials for anything beyond a quick test.'; + } + + if (intakeHint) { + intakeHint.textContent = 'Use the Box folder where the citizen portal should first upload the photo and metadata files.'; + } +} + +async function loadBoxConfig() { + const payload = await requestJson('/api/box/config'); + applyBoxConfigToForm(payload?.config || {}, payload?.status || {}); + return payload; +} + +async function saveBoxConfig(options = {}) { + const payload = await requestJson('/api/box/config', { + method: 'POST', + body: JSON.stringify(getBoxFormData()), + }); + applyBoxConfigToForm(payload?.config || {}, payload?.status || {}); + if (options.showSuccessToast) { + showToast('Settings saved successfully', 'success'); + } + return payload; +} + +async function resetBoxConfig() { + const payload = await requestJson('/api/box/config', { + method: 'POST', + body: JSON.stringify({ reset: true }), + }); + applyBoxConfigToForm(payload?.config || {}, payload?.status || {}); + return payload; +} + +function renderBoxWorkflowBlueprint() { + const usesEl = document.getElementById('box-workflow-uses'); + const inputsEl = document.getElementById('box-workflow-inputs'); + const stagesEl = document.getElementById('box-workflow-stages'); + const metadataEl = document.getElementById('box-workflow-metadata'); + if (!usesEl || !inputsEl || !stagesEl || !metadataEl) return; + + const folderId = normalizeString(document.getElementById(BOX_FIELD_IDS.intakeFolderId)?.value || ''); + const blueprint = window.BoxWorkflow?.buildWorkflowBlueprint + ? window.BoxWorkflow.buildWorkflowBlueprint({ folderId, folderName: 'Incoming Detections' }) + : null; + + const folderNameEl = document.getElementById('box-workflow-folder-name'); + const folderIdEl = document.getElementById('box-workflow-folder-id'); + if (folderNameEl) folderNameEl.textContent = blueprint?.triggerFolderName || 'Incoming Detections'; + if (folderIdEl) folderIdEl.textContent = blueprint?.triggerFolderId || 'Not set'; + + if (!blueprint) { + usesEl.innerHTML = '
Workflow helper not loaded.
'; + inputsEl.innerHTML = ''; + stagesEl.innerHTML = ''; + metadataEl.textContent = ''; + return; + } + + usesEl.innerHTML = blueprint.automationUses.map((item) => ` +
+ ${item} +
+ `).join(''); + + inputsEl.innerHTML = blueprint.inputFields.map((field) => ` +
+
+ ${field.key} + ${field.source} +
+
${field.purpose}
+
+ `).join(''); + + stagesEl.innerHTML = blueprint.stages.map((stage) => ` +
+
${stage.title}
+
${stage.use}
+
Input: ${stage.input}
+
+ `).join(''); + + metadataEl.textContent = blueprint.metadataKeys.map((key) => `- ${key}`).join('\n'); +} + +function initWorkflowBlueprint() { + const copyInputsBtn = document.getElementById('btn-copy-workflow-inputs'); + const copyBlueprintBtn = document.getElementById('btn-copy-workflow-blueprint'); + + if (copyInputsBtn) { + copyInputsBtn.addEventListener('click', async () => { + const fields = window.BoxWorkflow?.getWorkflowInputFields ? window.BoxWorkflow.getWorkflowInputFields() : []; + try { + await copyText(JSON.stringify(fields, null, 2)); + showToast('Field schema copied.', 'success'); + } catch (err) { + showToast(`Copy failed: ${err.message}`, 'error'); + } + }); + } + + if (copyBlueprintBtn) { + copyBlueprintBtn.addEventListener('click', async () => { + const folderId = normalizeString(document.getElementById(BOX_FIELD_IDS.intakeFolderId)?.value || ''); + const blueprint = window.BoxWorkflow?.buildWorkflowBlueprint + ? window.BoxWorkflow.buildWorkflowBlueprint({ folderId, folderName: 'Incoming Detections' }) + : {}; + try { + await copyText(JSON.stringify(blueprint, null, 2)); + showToast('Storage blueprint copied.', 'success'); + } catch (err) { + showToast(`Copy failed: ${err.message}`, 'error'); + } + }); + } + + Object.values(BOX_FIELD_IDS).forEach((id) => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', renderBoxWorkflowBlueprint); + }); + + renderBoxWorkflowBlueprint(); +} + +function initPinGate() { + const overlay = document.getElementById('pin-overlay'); + const pinInput = document.getElementById('pin-input'); + const pinSubmit = document.getElementById('pin-submit'); + const pinError = document.getElementById('pin-error'); + const settingsContent = document.getElementById('settings-content'); + if (!overlay) return; + + const storedPin = lsGet(STORAGE_KEYS.pin, DEFAULT_PIN); + overlay.style.display = 'flex'; + + function attemptUnlock() { + const entered = pinInput ? pinInput.value.trim() : ''; + if (entered === storedPin) { + overlay.style.display = 'none'; + if (settingsContent) settingsContent.style.display = 'block'; + if (pinError) pinError.textContent = ''; + return; + } + + if (pinError) pinError.textContent = 'Incorrect PIN. Please try again.'; + if (pinInput) { + pinInput.value = ''; + pinInput.focus(); + } + } + + pinSubmit?.addEventListener('click', attemptUnlock); + + if (pinInput) { + pinInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') attemptUnlock(); + }); + pinInput.addEventListener('input', () => { + pinInput.value = pinInput.value.replace(/\D/g, '').slice(0, 4); + }); + pinInput.focus(); + } +} + +function loadLocalSettings() { + const fieldMap = { + 'field-city': STORAGE_KEYS.city, + 'field-dept': STORAGE_KEYS.dept, + 'field-tm-url': STORAGE_KEYS.tmUrl, + 'field-gis-geojson-url': STORAGE_KEYS.gisGeoJsonUrl, + 'field-google-maps-key': STORAGE_KEYS.googleMapsJsKey, + 'field-gis-ward-prop': STORAGE_KEYS.gisWardProp, + 'field-gis-district-prop': STORAGE_KEYS.gisDistrictProp, + 'field-gis-zone-prop': STORAGE_KEYS.gisZoneProp, + 'field-weather-key': STORAGE_KEYS.weatherKey, + }; + + for (const [id, key] of Object.entries(fieldMap)) { + const el = document.getElementById(id); + if (el) el.value = lsGet(key, DEFAULT_SETTINGS[key] ?? ''); + } + + const anonEl = getFirstElement('field-anonymous', 'field-allow-anon'); + if (anonEl) { + const value = lsGet(STORAGE_KEYS.anonymous, DEFAULT_SETTINGS[STORAGE_KEYS.anonymous]); + if (anonEl.type === 'checkbox') anonEl.checked = value === 'true'; + else anonEl.value = value; + } +} + +function persistLocalSettings() { + const fieldMap = { + 'field-city': STORAGE_KEYS.city, + 'field-dept': STORAGE_KEYS.dept, + 'field-tm-url': STORAGE_KEYS.tmUrl, + 'field-gis-geojson-url': STORAGE_KEYS.gisGeoJsonUrl, + 'field-google-maps-key': STORAGE_KEYS.googleMapsJsKey, + 'field-gis-ward-prop': STORAGE_KEYS.gisWardProp, + 'field-gis-district-prop': STORAGE_KEYS.gisDistrictProp, + 'field-gis-zone-prop': STORAGE_KEYS.gisZoneProp, + 'field-weather-key': STORAGE_KEYS.weatherKey, + }; + + for (const [id, key] of Object.entries(fieldMap)) { + const el = document.getElementById(id); + if (el) lsSet(key, el.value.trim()); + } + + const anonEl = getFirstElement('field-anonymous', 'field-allow-anon'); + if (anonEl) { + const value = anonEl.type === 'checkbox' ? String(anonEl.checked) : anonEl.value; + lsSet(STORAGE_KEYS.anonymous, value); + } + + renderBoxWorkflowBlueprint(); +} + +async function saveSettings() { + persistLocalSettings(); + await saveBoxConfig({ showSuccessToast: true }); +} + +function initSettingsForm() { + const form = document.getElementById('settings-form'); + if (!form) return; + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + try { + await saveSettings(); + } catch (err) { + showToast(`Save failed: ${err.message}`, 'error', 5000); + } + }); +} + +function initTestConnection() { + const btn = document.getElementById('btn-test-connection'); + const status = document.getElementById('connection-status'); + if (!btn || !status) return; + + btn.addEventListener('click', async () => { + status.style.display = 'block'; + status.textContent = 'Saving Box repository settings and testing connection...'; + status.className = 'connection-status connection-status--pending'; + btn.disabled = true; + + try { + await saveBoxConfig(); + if (typeof testBoxConnection !== 'function') { + throw new Error('testBoxConnection() is not available. Ensure box-upload.js is loaded.'); + } + + const result = await testBoxConnection(); + if (result?.ok) { + const rootText = result.caseRootFolderName && result.caseRootFolderName !== result.folderName + ? ` | Case root: ${result.caseRootFolderName}` + : ''; + status.textContent = `Connected successfully: ${result.folderName}${rootText}`; + status.className = 'connection-status connection-status--success'; + } else { + status.textContent = result?.error || 'Connection failed.'; + status.className = 'connection-status connection-status--error'; + } + } catch (err) { + status.textContent = `Error: ${err.message}`; + status.className = 'connection-status connection-status--error'; + } finally { + btn.disabled = false; + } + }); +} + +function initPinChange() { + const btn = document.getElementById('btn-change-pin'); + const newPinEl = document.getElementById('field-new-pin'); + const confirmEl = document.getElementById('field-confirm-pin'); + if (!btn) return; + + btn.addEventListener('click', () => { + const newPin = newPinEl ? newPinEl.value.trim() : ''; + const confirmPin = confirmEl ? confirmEl.value.trim() : ''; + + if (!newPin) { + showToast('Please enter a new PIN.', 'error'); + return; + } + + if (!/^\d{4}$/.test(newPin)) { + showToast('PIN must be exactly 4 digits.', 'error'); + return; + } + + if (newPin !== confirmPin) { + showToast('PINs do not match. Please try again.', 'error'); + return; + } + + lsSet(STORAGE_KEYS.pin, newPin); + if (newPinEl) newPinEl.value = ''; + if (confirmEl) confirmEl.value = ''; + showToast('PIN updated successfully.', 'success'); + }); + + [newPinEl, confirmEl].forEach((el) => { + if (!el) return; + el.addEventListener('input', () => { + el.value = el.value.replace(/\D/g, '').slice(0, 4); + }); + }); +} + +function buildExportPayload() { + const local = {}; + for (const key of Object.values(STORAGE_KEYS)) { + local[key] = lsGet(key, DEFAULT_SETTINGS[key] ?? ''); + } + + return { + local, + box: getBoxFormData(), + }; +} + +function initExportSettings() { + const btn = document.getElementById('btn-export'); + if (!btn) return; + + btn.addEventListener('click', () => { + const json = JSON.stringify(buildExportPayload(), null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `pothole-portal-settings-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(url); + showToast('Settings exported.', 'info'); + }); +} + +function initImportSettings() { + const fileInput = document.getElementById('import-file'); + const btn = document.getElementById('btn-import'); + if (!fileInput) return; + + btn?.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = async (event) => { + try { + const parsed = JSON.parse(String(event.target?.result || '')); + const local = parsed?.local && typeof parsed.local === 'object' ? parsed.local : parsed; + const box = parsed?.box && typeof parsed.box === 'object' ? parsed.box : null; + + let imported = 0; + for (const [key, value] of Object.entries(local || {})) { + if (!Object.values(STORAGE_KEYS).includes(key)) continue; + lsSet(key, value); + imported += 1; + } + + loadLocalSettings(); + + if (box) { + const fieldMap = { + authMode: BOX_FIELD_IDS.authMode, + accessToken: BOX_FIELD_IDS.accessToken, + refreshToken: BOX_FIELD_IDS.refreshToken, + clientId: BOX_FIELD_IDS.clientId, + clientSecret: BOX_FIELD_IDS.clientSecret, + subjectType: BOX_FIELD_IDS.subjectType, + subjectId: BOX_FIELD_IDS.subjectId, + intakeFolderId: BOX_FIELD_IDS.intakeFolderId, + caseRootFolderId: BOX_FIELD_IDS.caseRootFolderId, + }; + for (const [key, id] of Object.entries(fieldMap)) { + const el = document.getElementById(id); + if (el && box[key] != null) el.value = String(box[key]); + } + await saveBoxConfig(); + } + + renderBoxWorkflowBlueprint(); + showToast(`Settings imported (${imported} local key${imported !== 1 ? 's' : ''}).`, 'success'); + } catch (err) { + showToast(`Import failed: ${err.message}`, 'error', 5000); + } finally { + fileInput.value = ''; + } + }; + + reader.onerror = () => { + showToast('Could not read file.', 'error'); + fileInput.value = ''; + }; + + reader.readAsText(file); + }); +} + +function initResetDefaults() { + const btn = document.getElementById('btn-reset'); + if (!btn) return; + + btn.addEventListener('click', async () => { + const confirmed = window.confirm( + 'Reset all settings to their defaults?\n\nThis will clear Box integration on the backend, city/department name, and reset the PIN to 1234.' + ); + if (!confirmed) return; + + for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { + lsSet(key, value); + } + + loadLocalSettings(); + try { + await resetBoxConfig(); + showToast('Settings reset to defaults.', 'info'); + } catch (err) { + showToast(`Reset failed: ${err.message}`, 'error', 5000); + } + }); +} + +function initRevealButtons() { + const revealPairs = [ + ['btn-reveal-access-token', 'field-access-token'], + ['btn-reveal-refresh-token', 'field-refresh-token'], + ['btn-reveal-client-secret', 'field-client-secret'], + ['btn-reveal-weather-key', 'field-weather-key'], + ]; + + revealPairs.forEach(([buttonId, inputId]) => { + const btn = document.getElementById(buttonId); + const input = document.getElementById(inputId); + if (!btn || !input) return; + + btn.addEventListener('click', () => { + const isPassword = input.type === 'password'; + input.type = isPassword ? 'text' : 'password'; + btn.style.color = isPassword ? 'var(--color-blue)' : 'var(--color-muted)'; + }); + }); +} + +async function initSettingsPage() { + initPinGate(); + const settingsContent = document.getElementById('settings-content'); + if (settingsContent) settingsContent.style.display = 'none'; + + loadLocalSettings(); + normalizeBoxFieldHints(); + renderBoxWorkflowBlueprint(); + initWorkflowBlueprint(); + initSettingsForm(); + initTestConnection(); + initRevealButtons(); + initPinChange(); + initExportSettings(); + initImportSettings(); + initResetDefaults(); + + try { + await loadBoxConfig(); + } catch (err) { + showToast(`Could not load Box settings: ${err.message}`, 'error', 5000); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + initSettingsPage().catch((err) => { + console.error('[settings] init failed:', err); + }); + }); +} else { + initSettingsPage().catch((err) => { + console.error('[settings] init failed:', err); + }); +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..0689d7e9d9417bd6ccf53b7a2c74eb179d88ebc9 --- /dev/null +++ b/styles.css @@ -0,0 +1,2908 @@ +/* ============================================================ + POTHOLE REPORTING PORTAL โ€” DESIGN SYSTEM + Box.com-inspired Enterprise SaaS UI + ============================================================ */ + +/* โ”€โ”€ Google Fonts Import โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + + +/* ============================================================ + 1. CSS RESET & BASE + ============================================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + + +html { + font-size: 16px; + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: var(--color-body-bg); + color: var(--color-text-dark); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + min-height: 100vh; +} + +img, video, svg { + display: block; + max-width: 100%; +} + +a { + color: var(--color-blue); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--color-blue-dark); +} + +ul, ol { + list-style: none; +} + +button, input, select, textarea { + font-family: inherit; + font-size: inherit; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + line-height: 1.2; + color: var(--color-text-dark); +} + + +/* ============================================================ + 2. ROOT DESIGN TOKENS + ============================================================ */ +:root { + /* โ”€โ”€ Brand Colors โ”€โ”€ */ + --color-blue: #0061D5; + --color-blue-dark: #004FA8; + --color-blue-light: #E8F0FB; + --color-blue-subtle: #F0F5FF; + + /* โ”€โ”€ Neutral Palette โ”€โ”€ */ + --color-text-dark: #1A1A2E; + --color-body-bg: #F7F9FC; + --color-card-bg: #FFFFFF; + --color-border: #E4E9F0; + --color-muted: #6B7280; + --color-muted-light: #9CA3AF; + --color-surface: #F3F6FA; + + /* โ”€โ”€ Semantic Colors โ”€โ”€ */ + --color-success: #26C281; + --color-success-bg: #EAFAF2; + --color-warning: #F4A300; + --color-warning-bg: #FFF8E6; + --color-danger: #E53E3E; + --color-danger-bg: #FEF2F2; + --color-info: #3B82F6; + --color-info-bg: #EFF6FF; + + /* โ”€โ”€ Hero / Dark palette โ”€โ”€ */ + --color-hero-from: #0A1628; + --color-hero-to: #1B3A6B; + --color-footer-bg: #0D1B2E; + + /* โ”€โ”€ Typography โ”€โ”€ */ + --font-xs: 0.75rem; /* 12px */ + --font-sm: 0.875rem; /* 14px */ + --font-base: 1rem; /* 16px */ + --font-md: 1.0625rem; /* 17px */ + --font-lg: 1.125rem; /* 18px */ + --font-xl: 1.25rem; /* 20px */ + --font-2xl: 1.5rem; /* 24px */ + --font-3xl: 1.875rem; /* 30px */ + --font-4xl: 2.25rem; /* 36px */ + --font-5xl: 3rem; /* 48px */ + + /* โ”€โ”€ Spacing โ”€โ”€ */ + --sp-1: 0.25rem; /* 4px */ + --sp-2: 0.5rem; /* 8px */ + --sp-3: 0.75rem; /* 12px */ + --sp-4: 1rem; /* 16px */ + --sp-5: 1.25rem; /* 20px */ + --sp-6: 1.5rem; /* 24px */ + --sp-8: 2rem; /* 32px */ + --sp-10: 2.5rem; /* 40px */ + --sp-12: 3rem; /* 48px */ + --sp-16: 4rem; /* 64px */ + --sp-20: 5rem; /* 80px */ + + /* โ”€โ”€ Border Radii โ”€โ”€ */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 20px; + --radius-pill: 50px; + --radius-full: 9999px; + + /* โ”€โ”€ Shadows โ”€โ”€ */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.10), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-blue: 0 0 0 3px rgba(0, 97, 213, 0.18); + + /* โ”€โ”€ Transitions โ”€โ”€ */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + --transition-smooth: 350ms cubic-bezier(0.4, 0, 0.2, 1); + + /* โ”€โ”€ Z-index layers โ”€โ”€ */ + --z-base: 1; + --z-dropdown: 100; + --z-sticky: 200; + --z-overlay: 300; + --z-modal: 400; + --z-toast: 500; +} + + +/* ============================================================ + 3. NAVIGATION HEADER + ============================================================ */ +.nav-header { + position: sticky; + top: 0; + z-index: var(--z-sticky); + background: var(--color-card-bg); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow-xs); + height: 64px; + display: flex; + align-items: center; +} + +.nav-header__inner { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--sp-6); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-8); +} + +.nav-header__logo { + display: flex; + align-items: center; + gap: var(--sp-3); + font-weight: 700; + font-size: var(--font-lg); + color: var(--color-text-dark); + text-decoration: none; + flex-shrink: 0; + transition: opacity var(--transition-fast); +} + +.nav-header__logo:hover { + opacity: 0.85; + color: var(--color-text-dark); +} + +.nav-header__logo-icon { + width: 36px; + height: 36px; + background: linear-gradient(135deg, var(--color-blue) 0%, var(--color-blue-dark) 100%); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: var(--font-lg); + box-shadow: 0 2px 8px rgba(0, 97, 213, 0.30); +} + +.nav-header__links { + display: flex; + align-items: center; + gap: var(--sp-1); +} + +.nav-header__link { + padding: var(--sp-2) var(--sp-4); + border-radius: var(--radius-md); + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-muted); + text-decoration: none; + transition: color var(--transition-fast), background var(--transition-fast); + white-space: nowrap; +} + +.nav-header__link:hover { + color: var(--color-text-dark); + background: var(--color-surface); +} + +.nav-header__link.active { + color: var(--color-blue); + background: var(--color-blue-light); + font-weight: 600; +} + +.nav-header__actions { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-shrink: 0; +} + +.nav-header__divider { + width: 1px; + height: 24px; + background: var(--color-border); +} + +/* ============================================================ + 3b. CITIZEN NAV COMPATIBILITY (#app-nav / .nav-wrap) + ============================================================ */ +#app-nav { + position: fixed; + top: 0; + left: 0; + z-index: 100; + background: #ffffff; + border-bottom: 1px solid var(--color-border); + height: 64px; + display: flex; + align-items: center; + box-shadow: var(--shadow-xs); + width: 100%; +} +.nav-wrap { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; +} +.nav-brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: inherit; +} +.brand-icon { + width: 36px; + height: 36px; + background: linear-gradient(135deg, var(--color-blue) 0%, var(--color-blue-dark) 100%); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0,97,213,0.28); +} +.brand-city { + font-size: 13px; + font-weight: 700; + color: var(--color-text-dark); +} +.brand-sys { + font-size: 11px; + color: var(--color-muted); +} +.nav-menu { + display: flex; + align-items: center; + gap: 4px; +} +.nav-item { + padding: 8px 14px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: var(--color-muted); + text-decoration: none; + transition: all 0.15s; +} +.nav-item:hover { + background: var(--color-surface); + color: var(--color-text-dark); +} +.nav-item.active { + background: var(--color-blue-light); + color: var(--color-blue); + font-weight: 600; +} +.nav-item--settings { + border: 1px solid var(--color-border); + margin-left: 8px; +} + +/* ============================================================ + 4. HERO SECTION + ============================================================ */ +.hero { + position: relative; + background: linear-gradient(135deg, var(--color-hero-from) 0%, var(--color-hero-to) 100%); + color: #ffffff; + text-align: center; + padding: var(--sp-20) var(--sp-6); + overflow: hidden; + isolation: isolate; +} + +/* CSS grid overlay pattern */ +.hero::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px); + background-size: 48px 48px; + z-index: 0; + pointer-events: none; +} + +/* Subtle radial glow */ +.hero::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse 70% 60% at 50% 40%, rgba(0, 97, 213, 0.22) 0%, transparent 70%); + z-index: 0; + pointer-events: none; +} + +.hero__content { + position: relative; + z-index: 1; + max-width: 720px; + margin: 0 auto; +} + +.hero__eyebrow { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + background: rgba(255, 255, 255, 0.10); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-pill); + padding: var(--sp-1) var(--sp-4); + font-size: var(--font-xs); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255,255,255,0.80); + margin-bottom: var(--sp-6); + backdrop-filter: blur(8px); +} + +.hero__title { + font-size: var(--font-5xl); + font-weight: 800; + line-height: 1.1; + color: #ffffff; + letter-spacing: -0.02em; + margin-bottom: var(--sp-5); +} + +.hero__title span { + background: linear-gradient(90deg, #60A5FA, #93C5FD); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero__subtitle { + font-size: var(--font-lg); + font-weight: 400; + color: rgba(255, 255, 255, 0.70); + max-width: 520px; + margin: 0 auto var(--sp-10); + line-height: 1.7; +} + +.hero__cta { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-4); + flex-wrap: wrap; +} + +.hero__stats { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-10); + margin-top: var(--sp-16); + padding-top: var(--sp-8); + border-top: 1px solid rgba(255,255,255,0.10); + flex-wrap: wrap; +} + +.hero__stat-value { + font-size: var(--font-3xl); + font-weight: 800; + color: #ffffff; + display: block; +} + +.hero__stat-label { + font-size: var(--font-xs); + color: rgba(255,255,255,0.55); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: var(--sp-1); +} + + +/* ============================================================ + 5. PROGRESS STEPS / STEP INDICATOR + ============================================================ */ +.step-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + padding: var(--sp-6) 0; + width: 100%; +} + +.step-indicator__item { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + flex: 1; + max-width: 180px; +} + +/* Connecting line */ +.step-indicator__item:not(:last-child)::after { + content: ''; + position: absolute; + top: 18px; + left: calc(50% + 20px); + right: calc(-50% + 20px); + height: 2px; + background: var(--color-border); + transition: background var(--transition-smooth); + z-index: 0; +} + +.step-indicator__item.done:not(:last-child)::after { + background: var(--color-blue); +} + +.step-indicator__bubble { + width: 36px; + height: 36px; + border-radius: var(--radius-full); + border: 2px solid var(--color-border); + background: var(--color-card-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-sm); + font-weight: 600; + color: var(--color-muted); + position: relative; + z-index: 1; + transition: + background var(--transition-smooth), + border-color var(--transition-smooth), + color var(--transition-smooth), + box-shadow var(--transition-smooth); +} + +.step-indicator__item.active .step-indicator__bubble { + background: var(--color-blue); + border-color: var(--color-blue); + color: #ffffff; + box-shadow: 0 0 0 4px rgba(0, 97, 213, 0.18); +} + +.step-indicator__item.done .step-indicator__bubble { + background: var(--color-blue); + border-color: var(--color-blue); + color: #ffffff; +} + +.step-indicator__item.done .step-indicator__bubble::after { + content: 'โœ“'; + font-size: var(--font-sm); + font-weight: 700; +} + +.step-indicator__label { + margin-top: var(--sp-2); + font-size: var(--font-xs); + font-weight: 500; + color: var(--color-muted); + text-align: center; + transition: color var(--transition-fast); + white-space: nowrap; +} + +.step-indicator__item.active .step-indicator__label { + color: var(--color-blue); + font-weight: 600; +} + +.step-indicator__item.done .step-indicator__label { + color: var(--color-text-dark); +} + + +/* ============================================================ + 6. UPLOAD ZONE + ============================================================ */ +.upload-zone { + border: 2px dashed var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + padding: var(--sp-12) var(--sp-8); + text-align: center; + cursor: pointer; + transition: + border-color var(--transition-smooth), + background var(--transition-smooth), + box-shadow var(--transition-smooth); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-4); + position: relative; + overflow: hidden; +} + +.upload-zone:hover { + border-color: var(--color-blue); + background: var(--color-blue-subtle); + box-shadow: var(--shadow-blue); +} + +.upload-zone.drag-over { + border-color: var(--color-blue); + background: var(--color-blue-light); + box-shadow: var(--shadow-blue), inset 0 0 0 1px var(--color-blue); + transform: scale(1.01); +} + +.upload-zone__icon { + width: 56px; + height: 56px; + border-radius: var(--radius-xl); + background: var(--color-blue-light); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-blue); + font-size: 1.5rem; + transition: background var(--transition-smooth), transform var(--transition-smooth); +} + +.upload-zone:hover .upload-zone__icon, +.upload-zone.drag-over .upload-zone__icon { + background: var(--color-blue); + color: #ffffff; + transform: translateY(-2px); +} + +.upload-zone__title { + font-size: var(--font-md); + font-weight: 600; + color: var(--color-text-dark); +} + +.upload-zone__sub { + font-size: var(--font-sm); + color: var(--color-muted); +} + +.upload-zone__link { + color: var(--color-blue); + font-weight: 600; +} + +.upload-zone__meta { + font-size: var(--font-xs); + color: var(--color-muted-light); +} + +.upload-zone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} + +/* Image previews */ +.upload-zone__previews { + display: flex; + flex-wrap: wrap; + gap: var(--sp-3); + margin-top: var(--sp-4); + justify-content: center; +} + +.upload-zone__preview-thumb { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: var(--radius-md); + border: 2px solid var(--color-border); + transition: border-color var(--transition-fast), transform var(--transition-fast); +} + +.upload-zone__preview-thumb:hover { + border-color: var(--color-blue); + transform: scale(1.05); +} + + +/* ============================================================ + 7. MAP CONTAINER + ============================================================ */ +.map-wrap { + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--color-border); + box-shadow: var(--shadow-md); + background: var(--color-surface); + height: 300px; + position: relative; +} + +.map-wrap iframe, +.map-wrap .map-embed { + width: 100%; + height: 100%; + border: none; + display: block; +} + +.map-wrap__overlay { + position: absolute; + inset: 0; + background: rgba(26, 26, 46, 0.04); + pointer-events: none; + border-radius: var(--radius-lg); +} + +.map-wrap__label { + position: absolute; + bottom: var(--sp-3); + left: var(--sp-3); + background: rgba(255, 255, 255, 0.95); + border-radius: var(--radius-md); + padding: var(--sp-2) var(--sp-3); + font-size: var(--font-xs); + font-weight: 600; + color: var(--color-text-dark); + box-shadow: var(--shadow-sm); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.map-wrap__loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-surface); + gap: var(--sp-3); + color: var(--color-muted); + font-size: var(--font-sm); +} + + +/* ============================================================ + 8. FORM INPUTS + ============================================================ */ +.field-group { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.field-label { + font-size: var(--font-sm); + font-weight: 600; + color: var(--color-text-dark); + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.field-label .required { + color: var(--color-danger); + font-weight: 700; +} + +.field-label .optional { + font-size: var(--font-xs); + font-weight: 400; + color: var(--color-muted-light); +} + +.field-input, +.field-select, +.field-textarea { + width: 100%; + padding: var(--sp-3) var(--sp-4); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-card-bg); + color: var(--color-text-dark); + font-size: var(--font-sm); + font-weight: 400; + line-height: 1.5; + outline: none; + transition: + border-color var(--transition-base), + box-shadow var(--transition-base), + background var(--transition-fast); + -webkit-appearance: none; + appearance: none; +} + +.field-input::placeholder, +.field-textarea::placeholder { + color: var(--color-muted-light); + font-weight: 400; +} + +.field-input:hover, +.field-select:hover, +.field-textarea:hover { + border-color: #C4CFDE; +} + +.field-input:focus, +.field-select:focus, +.field-textarea:focus { + border-color: var(--color-blue); + box-shadow: 0 0 0 3px rgba(0, 97, 213, 0.14); + background: var(--color-card-bg); +} + +.field-input.error, +.field-select.error, +.field-textarea.error { + border-color: var(--color-danger); + box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.12); +} + +.field-input.success, +.field-select.success, +.field-textarea.success { + border-color: var(--color-success); + box-shadow: 0 0 0 3px rgba(38, 194, 129, 0.12); +} + +.field-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236B7280' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--sp-4) center; + padding-right: var(--sp-10); + cursor: pointer; +} + +.field-textarea { + resize: vertical; + min-height: 100px; +} + +.field-hint { + font-size: var(--font-xs); + color: var(--color-muted); + line-height: 1.5; +} + +.field-error { + font-size: var(--font-xs); + color: var(--color-danger); + font-weight: 500; + display: flex; + align-items: center; + gap: var(--sp-1); +} + +/* Input with icon prefix */ +.field-input-wrap { + position: relative; + display: flex; + align-items: center; +} + +.field-input-wrap .field-input { + padding-left: 2.75rem; +} + +.field-input-wrap .field-icon { + position: absolute; + left: var(--sp-3); + color: var(--color-muted); + pointer-events: none; + font-size: 1rem; + transition: color var(--transition-fast); +} + +.field-input-wrap:focus-within .field-icon { + color: var(--color-blue); +} + + +/* ============================================================ + 9. BUTTONS + ============================================================ */ + +/* Base button */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-6); + border-radius: var(--radius-md); + font-size: var(--font-sm); + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + border: 1.5px solid transparent; + text-decoration: none; + transition: + background var(--transition-fast), + border-color var(--transition-fast), + box-shadow var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); + position: relative; + overflow: hidden; + user-select: none; + -webkit-user-select: none; + vertical-align: middle; +} + +.btn:disabled, +.btn[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +/* Ripple shimmer on hover */ +.btn::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(255,255,255,0); + transition: background var(--transition-fast); +} +.btn:hover::after { + background: rgba(255,255,255,0.08); +} + +/* Primary */ +.btn-primary { + background: var(--color-blue); + border-color: var(--color-blue); + color: #ffffff; + box-shadow: 0 1px 3px rgba(0, 97, 213, 0.30); +} + +.btn-primary:hover { + background: var(--color-blue-dark); + border-color: var(--color-blue-dark); + box-shadow: 0 4px 12px rgba(0, 97, 213, 0.35); + color: #ffffff; +} + +.btn-primary:focus-visible { + outline: 3px solid rgba(0, 97, 213, 0.40); + outline-offset: 2px; +} + +.btn-primary:active:not(:disabled) { + background: #003E99; + border-color: #003E99; +} + +/* Secondary */ +.btn-secondary { + background: var(--color-card-bg); + border-color: var(--color-border); + color: var(--color-text-dark); + box-shadow: var(--shadow-xs); +} + +.btn-secondary:hover { + background: var(--color-surface); + border-color: #C4CFDE; + color: var(--color-text-dark); + box-shadow: var(--shadow-sm); +} + +.btn-secondary:focus-visible { + outline: 3px solid rgba(0, 97, 213, 0.25); + outline-offset: 2px; +} + +.btn-secondary:active:not(:disabled) { + background: #EDF1F7; +} + +/* Ghost */ +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--color-blue); +} + +.btn-ghost:hover { + background: var(--color-blue-light); + color: var(--color-blue-dark); +} + +.btn-ghost:focus-visible { + outline: 3px solid rgba(0, 97, 213, 0.25); + outline-offset: 2px; +} + +.btn-ghost:active:not(:disabled) { + background: var(--color-blue-light); +} + +/* Danger variant */ +.btn-danger { + background: var(--color-danger); + border-color: var(--color-danger); + color: #ffffff; + box-shadow: 0 1px 3px rgba(229, 62, 62, 0.28); +} + +.btn-danger:hover { + background: #C53030; + border-color: #C53030; + color: #ffffff; +} + +/* Success variant */ +.btn-success { + background: var(--color-success); + border-color: var(--color-success); + color: #ffffff; +} + +.btn-success:hover { + background: #1EAA6E; + border-color: #1EAA6E; + color: #ffffff; +} + +/* Size modifiers */ +.btn-sm { + padding: var(--sp-2) var(--sp-4); + font-size: var(--font-xs); + border-radius: var(--radius-md); +} + +.btn-lg { + padding: var(--sp-4) var(--sp-8); + font-size: var(--font-md); + border-radius: var(--radius-lg); +} + +.btn-xl { + padding: var(--sp-5) var(--sp-10); + font-size: var(--font-lg); + border-radius: var(--radius-lg); +} + +.btn-icon { + padding: var(--sp-2); + width: 36px; + height: 36px; + border-radius: var(--radius-md); +} + +/* Loading state */ +.btn.loading { + pointer-events: none; + opacity: 0.8; +} + +.btn.loading::before { + content: ''; + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.4); + border-top-color: #ffffff; + border-radius: var(--radius-full); + animation: spin 600ms linear infinite; + flex-shrink: 0; +} + +.btn-secondary.loading::before { + border-color: rgba(0,0,0,0.15); + border-top-color: var(--color-text-dark); +} + + +/* ============================================================ + 10. CARDS + ============================================================ */ +.card { + background: var(--color-card-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--sp-6); + box-shadow: var(--shadow-sm); + transition: box-shadow var(--transition-smooth), border-color var(--transition-smooth); +} + +.card:hover { + box-shadow: var(--shadow-md); +} + +.card--interactive { + cursor: pointer; +} + +.card--interactive:hover { + border-color: #C4CFDE; + box-shadow: var(--shadow-md); +} + +.card--interactive:active { + box-shadow: var(--shadow-sm); + transform: translateY(1px); + transition: transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.card--highlighted { + border-color: var(--color-blue); + box-shadow: 0 0 0 1px var(--color-blue), var(--shadow-md); +} + +.card--flat { + box-shadow: none; +} + +.card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-4); + margin-bottom: var(--sp-5); + padding-bottom: var(--sp-5); + border-bottom: 1px solid var(--color-border); +} + +.card__title { + font-size: var(--font-lg); + font-weight: 700; + color: var(--color-text-dark); +} + +.card__subtitle { + font-size: var(--font-sm); + color: var(--color-muted); + margin-top: var(--sp-1); +} + +.card__body { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.card__footer { + margin-top: var(--sp-5); + padding-top: var(--sp-5); + border-top: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--sp-3); +} + + +/* ============================================================ + 11. GPS VERIFICATION BADGE + ============================================================ */ +.gps-badge { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-4); + border-radius: var(--radius-pill); + font-size: var(--font-xs); + font-weight: 600; + line-height: 1; + white-space: nowrap; + border: 1.5px solid transparent; + transition: all var(--transition-base); +} + +.gps-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: var(--radius-full); + flex-shrink: 0; +} + +/* Default / pending */ +.gps-badge { + background: var(--color-surface); + border-color: var(--color-border); + color: var(--color-muted); +} + +.gps-badge::before { + background: var(--color-muted-light); +} + +/* Verified */ +.gps-badge.verified { + background: var(--color-success-bg); + border-color: rgba(38, 194, 129, 0.30); + color: #16A366; +} + +.gps-badge.verified::before { + background: var(--color-success); +} + +/* Mismatch */ +.gps-badge.mismatch { + background: var(--color-danger-bg); + border-color: rgba(229, 62, 62, 0.25); + color: var(--color-danger); +} + +.gps-badge.mismatch::before { + background: var(--color-danger); +} + +/* Manual entry */ +.gps-badge.manual { + background: var(--color-warning-bg); + border-color: rgba(244, 163, 0, 0.25); + color: #B97700; +} + +.gps-badge.manual::before { + background: var(--color-warning); +} + +/* Verifying (animated) */ +.gps-badge.verifying { + background: var(--color-info-bg); + border-color: rgba(59, 130, 246, 0.25); + color: var(--color-info); +} + +.gps-badge.verifying::before { + background: var(--color-info); + animation: pulse 1.2s ease-in-out infinite; +} + + +/* ============================================================ + 12. SUCCESS SCREEN + ============================================================ */ +.success-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: var(--sp-16) var(--sp-6); + gap: var(--sp-5); + animation: fadeInUp 0.5s var(--transition-smooth) both; +} + +.success-screen__check { + width: 80px; + height: 80px; + border-radius: var(--radius-full); + background: var(--color-success-bg); + border: 3px solid var(--color-success); + display: flex; + align-items: center; + justify-content: center; + animation: popIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s both; + position: relative; +} + +/* Animated SVG checkmark */ +.success-screen__check svg { + width: 40px; + height: 40px; +} + +.success-screen__check-path { + stroke: var(--color-success); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + stroke-dasharray: 60; + stroke-dashoffset: 60; + animation: drawCheck 0.4s ease 0.6s forwards; +} + +/* Expanding ring */ +.success-screen__ring { + position: absolute; + inset: -8px; + border-radius: var(--radius-full); + border: 2px solid var(--color-success); + opacity: 0; + animation: ringPulse 0.6s ease 0.5s forwards; +} + +.success-screen__title { + font-size: var(--font-2xl); + font-weight: 800; + color: var(--color-text-dark); +} + +.success-screen__subtitle { + font-size: var(--font-base); + color: var(--color-muted); + max-width: 380px; + line-height: 1.6; +} + +.success-screen__ref { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--sp-4) var(--sp-6); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-1); +} + +.success-screen__ref-label { + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-muted); +} + +.success-screen__ref-value { + font-size: var(--font-xl); + font-weight: 800; + color: var(--color-blue); + letter-spacing: 0.04em; +} + + +/* ============================================================ + 13. SETTINGS PAGE + ============================================================ */ +.settings-wrap { + max-width: 760px; + margin: 0 auto; + padding: var(--sp-8) var(--sp-6); + display: flex; + flex-direction: column; + gap: var(--sp-6); +} + +.settings-header { + display: flex; + align-items: center; + gap: var(--sp-4); + margin-bottom: var(--sp-2); +} + +.settings-header-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-xl); + background: var(--color-blue-light); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-blue); + box-shadow: 0 2px 8px rgba(0,97,213,0.15); +} + +.settings-title { + font-size: var(--font-2xl); + font-weight: 800; + color: var(--color-text-dark); +} + +.settings-sub { + font-size: var(--font-sm); + color: var(--color-muted); + margin-top: 2px; +} + +/* Settings Cards */ +.settings-card { + margin-bottom: var(--sp-2); +} + +.settings-card-head { + display: flex; + align-items: center; + gap: var(--sp-4); + margin-bottom: var(--sp-4); +} + +.settings-card-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: var(--shadow-sm); +} + +.settings-card-title { + font-size: var(--font-md); + font-weight: 700; + color: var(--color-text-dark); +} + +.settings-card-sub { + font-size: var(--font-xs); + color: var(--color-muted); + margin-top: 1px; +} + +/* Settings Group Container */ +.settings-group { + background: var(--color-card-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; +} + +/* Settings Row - Default Vertical Stack for Inputs */ +.settings-row { + display: flex; + flex-direction: column; + align-items: stretch; + padding: var(--sp-5) var(--sp-6); + border-bottom: 1px solid var(--color-border); + gap: var(--sp-2); + transition: background var(--transition-fast); +} + +.settings-row:last-child { + border-bottom: none; +} + +/* 2-Column Grid Layout for Desktop Settings */ +.settings-group > .settings-row-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--sp-4); + padding: var(--sp-5) var(--sp-6); + border-bottom: 1px solid var(--color-border); +} + +/* Remove nested row styles when inside row-2 */ +.settings-row-2 .settings-row { + padding: 0; + border-bottom: none; + background: transparent; +} + +/* PIN Input grid support */ +.settings-row .settings-row-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--sp-4); + padding: 0; + border-bottom: none; + margin-top: 4px; +} + +/* Test connection button alignment */ +#btn-test-connection { + align-self: flex-start; + margin-top: 4px; +} + +/* Token Input Show/Hide Wrapper */ +.token-input-wrap { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.token-input-wrap .field-input { + padding-right: 3rem; +} + +.btn-reveal { + position: absolute; + right: 4px; + height: 34px; + width: 38px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-muted); + background: transparent; + border: none; + cursor: pointer; + border-radius: var(--radius-md); + transition: color var(--transition-fast), background var(--transition-fast); +} + +.btn-reveal:hover { + color: var(--color-text-dark); + background: var(--color-surface); +} + +.btn-reveal svg { + stroke-width: 2.2px; +} + +/* Connection Test Banner Alert */ +.connection-banner { + padding: var(--sp-4) var(--sp-5); + border-radius: var(--radius-lg); + font-size: var(--font-sm); + font-weight: 500; + display: flex; + align-items: center; + gap: var(--sp-3); + animation: fadeInDown 0.3s ease; + margin-bottom: var(--sp-4); +} + +.connection-banner.success, +.connection-status--success { + background: var(--color-success-bg); + color: #16A366; + border: 1.5px solid rgba(38, 194, 129, 0.30); +} + +.connection-banner.error, +.connection-status--error { + background: var(--color-danger-bg); + color: var(--color-danger); + border: 1.5px solid rgba(229, 62, 62, 0.25); +} + +.connection-banner.pending, +.connection-status--pending { + background: var(--color-blue-subtle); + color: var(--color-blue); + border: 1.5px solid rgba(0, 97, 213, 0.20); +} + +/* Action Buttons Footer */ +.settings-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); + padding: var(--sp-6) 0 var(--sp-12); + border-top: 1px solid var(--color-border); + margin-top: var(--sp-6); + flex-wrap: wrap; +} + +.settings-actions-left { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.btn-ghost.btn-danger { + color: var(--color-danger); +} + +.btn-ghost.btn-danger:hover { + background: var(--color-danger-bg); + color: #C53030; +} + +/* Toggle Switch Slider Controls */ +.toggle-switch { + position: relative; + width: 44px; + height: 24px; + flex-shrink: 0; + display: inline-block; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-track { + position: absolute; + inset: 0; + background: var(--color-border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: background var(--transition-smooth); +} + +.toggle-track::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border-radius: var(--radius-full); + background: #ffffff; + top: 3px; + left: 3px; + box-shadow: var(--shadow-sm); + transition: transform var(--transition-smooth); +} + +.toggle-switch input:checked + .toggle-track { + background: var(--color-blue); +} + +.toggle-switch input:checked + .toggle-track::after { + transform: translateX(20px); +} + +.toggle-switch input:focus-visible + .toggle-track { + outline: 3px solid rgba(0, 97, 213, 0.30); + outline-offset: 2px; +} + +.toggle-wrap { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-top: var(--sp-1); +} + +.toggle-label { + font-size: var(--font-sm); + color: var(--color-muted); +} + +/* PIN Gate */ +.pin-gate { + background: var(--color-card-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + padding: var(--sp-10); + max-width: 400px; + margin: var(--sp-10) auto; + text-align: center; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-5); +} + +.pin-gate__icon { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background: var(--color-blue-light); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + color: var(--color-blue); +} + +.pin-gate__title { + font-size: var(--font-xl); + font-weight: 700; +} + +.pin-gate__desc { + font-size: var(--font-sm); + color: var(--color-muted); + max-width: 280px; + line-height: 1.6; +} + +.pin-gate__inputs { + display: flex; + gap: var(--sp-3); + justify-content: center; +} + +.pin-gate__input { + width: 52px; + height: 60px; + text-align: center; + font-size: var(--font-2xl); + font-weight: 700; + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + outline: none; + transition: border-color var(--transition-base), box-shadow var(--transition-base); + -webkit-appearance: none; + appearance: none; +} + +.pin-gate__input:focus { + border-color: var(--color-blue); + box-shadow: var(--shadow-blue); +} + +.pin-gate__input.error { + border-color: var(--color-danger); + animation: shake 0.3s ease; +} + + +/* ============================================================ + 14. TRACK PAGE + ============================================================ */ +.track-wrap { + max-width: 720px; + margin: 0 auto; + padding: var(--sp-8) var(--sp-6); + display: flex; + flex-direction: column; + gap: var(--sp-6); +} + +.track-wrap__title { + font-size: var(--font-2xl); + font-weight: 800; + color: var(--color-text-dark); +} + +.track-wrap__header { + display: flex; + align-items: flex-start; + gap: var(--sp-4); + flex-wrap: wrap; +} + +.track-wrap__meta { + flex: 1; + min-width: 0; +} + +/* Timeline */ +.timeline { + display: flex; + flex-direction: column; + gap: 0; + position: relative; + padding-left: 32px; +} + +.timeline::before { + content: ''; + position: absolute; + left: 11px; + top: 0; + bottom: 0; + width: 2px; + background: var(--color-border); +} + +.timeline-item { + position: relative; + padding: 0 0 var(--sp-8) var(--sp-5); + transition: opacity var(--transition-base); +} + +.timeline-item:last-child { + padding-bottom: 0; +} + +.timeline-item__dot { + position: absolute; + left: calc(-32px + 6px); + top: 3px; + width: 14px; + height: 14px; + border-radius: var(--radius-full); + border: 2px solid var(--color-border); + background: var(--color-card-bg); + z-index: 1; + transition: background var(--transition-smooth), border-color var(--transition-smooth), box-shadow var(--transition-smooth); +} + +.timeline-item.active .timeline-item__dot { + background: var(--color-blue); + border-color: var(--color-blue); + box-shadow: 0 0 0 3px rgba(0, 97, 213, 0.18); +} + +.timeline-item.done .timeline-item__dot { + background: var(--color-success); + border-color: var(--color-success); +} + +.timeline-item.pending .timeline-item__dot { + background: var(--color-card-bg); + border-color: var(--color-border); +} + +.timeline-item__header { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-2); +} + +.timeline-item__title { + font-size: var(--font-sm); + font-weight: 700; + color: var(--color-text-dark); +} + +.timeline-item.pending .timeline-item__title { + color: var(--color-muted); +} + +.timeline-item__date { + font-size: var(--font-xs); + color: var(--color-muted-light); + margin-left: auto; + white-space: nowrap; +} + +.timeline-item__body { + font-size: var(--font-sm); + color: var(--color-muted); + line-height: 1.6; +} + +.timeline-item__tag { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + padding: 2px var(--sp-3); + font-size: var(--font-xs); + font-weight: 600; + color: var(--color-muted); + margin-top: var(--sp-2); +} + + +/* ============================================================ + 15. TOAST NOTIFICATIONS + ============================================================ */ +.toast-container { + position: fixed; + bottom: var(--sp-6); + right: var(--sp-6); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--sp-3); + pointer-events: none; + max-width: 380px; + width: calc(100% - var(--sp-8)); +} + +.toast { + background: var(--color-card-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--sp-4) var(--sp-5); + box-shadow: var(--shadow-xl); + display: flex; + align-items: flex-start; + gap: var(--sp-3); + pointer-events: all; + opacity: 0; + transform: translateX(calc(100% + var(--sp-6))); + transition: + opacity var(--transition-smooth), + transform var(--transition-smooth); + overflow: hidden; + position: relative; +} + +.toast.show, +.toast.toast--visible { + opacity: 1; + transform: translateX(0); +} + +.toast.hide { + opacity: 0; + transform: translateX(calc(100% + var(--sp-6))); +} + +/* Progress bar */ +.toast::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + height: 2px; + width: 100%; + background: var(--color-blue); + transform-origin: left; + animation: toastProgress 4s linear forwards; +} + +.toast--success::after { background: var(--color-success); } +.toast--warning::after { background: var(--color-warning); } +.toast--danger::after { background: var(--color-danger); } + +.toast__icon { + width: 32px; + height: 32px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + flex-shrink: 0; + background: var(--color-blue-light); + color: var(--color-blue); +} + +.toast--success .toast__icon { + background: var(--color-success-bg); + color: var(--color-success); +} + +.toast--warning .toast__icon { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.toast--danger .toast__icon { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.toast__content { + flex: 1; + min-width: 0; +} + +.toast__title { + font-size: var(--font-sm); + font-weight: 700; + color: var(--color-text-dark); + line-height: 1.3; +} + +.toast__message { + font-size: var(--font-xs); + color: var(--color-muted); + margin-top: 3px; + line-height: 1.5; +} + +.toast__close { + padding: var(--sp-1); + border-radius: var(--radius-sm); + color: var(--color-muted-light); + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast); + flex-shrink: 0; + align-self: flex-start; +} + +.toast__close:hover { + color: var(--color-text-dark); + background: var(--color-surface); +} + + +/* ============================================================ + 16. MOBILE RESPONSIVE โ€” 480px BREAKPOINT + ============================================================ */ +@media (max-width: 480px) { + :root { + --sp-6: 1rem; + } + + .nav-header { + height: 56px; + } + + .nav-header__inner { + padding: 0 var(--sp-4); + } + + .nav-header__links { + display: none; + } + + .nav-header__logo span { + display: none; + } + + /* Hero */ + .hero { + padding: var(--sp-12) var(--sp-4); + } + + .hero__title { + font-size: var(--font-3xl); + } + + .hero__subtitle { + font-size: var(--font-base); + } + + .hero__cta { + flex-direction: column; + gap: var(--sp-3); + } + + .hero__cta .btn { + width: 100%; + } + + .hero__stats { + gap: var(--sp-6); + } + + /* Steps */ + .step-indicator__label { + display: none; + } + + /* Wizard */ + .wizard-wrap { + padding: var(--sp-4); + border-radius: 0; + box-shadow: none; + } + + /* Cards */ + .card { + padding: var(--sp-4); + border-radius: var(--radius-md); + } + + /* Buttons */ + .btn-xl { + padding: var(--sp-4) var(--sp-6); + font-size: var(--font-base); + width: 100%; + } + + /* Upload zone */ + .upload-zone { + padding: var(--sp-8) var(--sp-4); + } + + /* Map */ + .map-wrap { + height: 220px; + } + + /* Toast */ + .toast-container { + bottom: var(--sp-4); + right: var(--sp-3); + left: var(--sp-3); + max-width: none; + width: auto; + } + + /* Settings */ + .settings-wrap { + padding: var(--sp-4); + } + + .settings-row { + flex-direction: column; + align-items: flex-start; + gap: var(--sp-3); + } + + .settings-row .toggle { + align-self: flex-start; + } + + /* Track */ + .track-wrap { + padding: var(--sp-4); + } + + /* PIN gate */ + .pin-gate { + padding: var(--sp-6); + } + + .pin-gate__input { + width: 44px; + height: 52px; + font-size: var(--font-xl); + } + + /* Timeline */ + .timeline-item__date { + display: none; + } + + /* Footer */ + .portal-footer__links { + flex-direction: column; + gap: var(--sp-4); + } + + .portal-footer__bottom { + flex-direction: column; + text-align: center; + gap: var(--sp-3); + } +} + + +/* ============================================================ + 17. WIZARD CONTAINER + ============================================================ */ +.wizard-wrap { + max-width: 680px; + margin: 0 auto; + padding: var(--sp-8) var(--sp-6); + background: var(--color-card-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); +} + +/* Step panels */ +.step-panel { + display: none; + flex-direction: column; + gap: var(--sp-6); + animation: fadeInUp 0.3s var(--transition-smooth) both; +} + +.step-panel.active { + display: flex; +} + +.step-panel__title { + font-size: var(--font-2xl); + font-weight: 800; + color: var(--color-text-dark); + letter-spacing: -0.01em; +} + +.step-panel__subtitle { + font-size: var(--font-sm); + color: var(--color-muted); + margin-top: var(--sp-1); + line-height: 1.6; +} + +.step-panel__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); + padding-top: var(--sp-4); + border-top: 1px solid var(--color-border); + flex-wrap: wrap; +} + +.step-panel__actions-right { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-left: auto; +} + + +/* ============================================================ + 18. AI BADGE + ============================================================ */ +.ai-badge { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%); + border: 1.5px solid rgba(99, 102, 241, 0.25); + border-radius: var(--radius-pill); + padding: var(--sp-2) var(--sp-4); + font-size: var(--font-xs); + font-weight: 700; + color: #4338CA; + white-space: nowrap; + transition: all var(--transition-smooth); +} + +.ai-badge__icon { + font-size: 0.875rem; + animation: sparkle 2s ease-in-out infinite; +} + +.ai-badge__label { + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ai-badge__value { + font-weight: 800; + font-size: var(--font-sm); +} + +.ai-badge__confidence { + font-weight: 400; + color: #6366F1; + font-size: var(--font-xs); +} + +/* Size variants */ +.ai-badge--low { + background: linear-gradient(135deg, var(--color-warning-bg), #FFF3D0); + border-color: rgba(244, 163, 0, 0.30); + color: #92550A; +} + +.ai-badge--low .ai-badge__confidence { + color: var(--color-warning); +} + +.ai-badge--medium { + background: linear-gradient(135deg, var(--color-info-bg), #DBEAFE); + border-color: rgba(59, 130, 246, 0.25); + color: #1D4ED8; +} + +.ai-badge--medium .ai-badge__confidence { + color: var(--color-info); +} + +.ai-badge--high { + background: linear-gradient(135deg, var(--color-danger-bg), #FEE2E2); + border-color: rgba(229, 62, 62, 0.25); + color: #9B1C1C; +} + +.ai-badge--high .ai-badge__confidence { + color: var(--color-danger); +} + +.ai-badge--critical { + background: linear-gradient(135deg, #FDF2F8, #FCE7F3); + border-color: rgba(219, 39, 119, 0.25); + color: #831843; +} + + +/* ============================================================ + 19. PORTAL FOOTER + ============================================================ */ +.portal-footer { + background: var(--color-footer-bg); + color: rgba(255,255,255,0.75); + padding: var(--sp-16) var(--sp-6) var(--sp-8); + margin-top: auto; +} + +.portal-footer__inner { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 280px 1fr 1fr 1fr; + gap: var(--sp-10); +} + +/* City seal / branding area */ +.portal-footer__brand { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.portal-footer__seal { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background: rgba(255,255,255,0.08); + border: 2px solid rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + color: rgba(255,255,255,0.60); + transition: background var(--transition-smooth), border-color var(--transition-smooth); +} + +.portal-footer__seal:hover { + background: rgba(255,255,255,0.12); + border-color: rgba(255,255,255,0.20); +} + +.portal-footer__brand-name { + font-size: var(--font-base); + font-weight: 700; + color: #ffffff; +} + +.portal-footer__brand-desc { + font-size: var(--font-xs); + color: rgba(255,255,255,0.45); + line-height: 1.7; +} + +.portal-footer__col-title { + font-size: var(--font-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255,255,255,0.40); + margin-bottom: var(--sp-4); +} + +.portal-footer__links { + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.portal-footer__link { + font-size: var(--font-sm); + color: rgba(255,255,255,0.60); + text-decoration: none; + transition: color var(--transition-fast); + line-height: 1.4; +} + +.portal-footer__link:hover { + color: #ffffff; +} + +.portal-footer__bottom { + max-width: 1200px; + margin: var(--sp-10) auto 0; + padding-top: var(--sp-6); + border-top: 1px solid rgba(255,255,255,0.08); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); + flex-wrap: wrap; +} + +.portal-footer__copyright { + font-size: var(--font-xs); + color: rgba(255,255,255,0.35); +} + +.portal-footer__bottom-links { + display: flex; + align-items: center; + gap: var(--sp-5); +} + +.portal-footer__bottom-link { + font-size: var(--font-xs); + color: rgba(255,255,255,0.40); + text-decoration: none; + transition: color var(--transition-fast); +} + +.portal-footer__bottom-link:hover { + color: rgba(255,255,255,0.75); +} + +/* Responsive footer */ +@media (max-width: 900px) { + .portal-footer__inner { + grid-template-columns: 1fr 1fr; + gap: var(--sp-8); + } +} + +@media (max-width: 480px) { + .portal-footer { + padding: var(--sp-10) var(--sp-4) var(--sp-6); + } + + .portal-footer__inner { + grid-template-columns: 1fr; + gap: var(--sp-6); + } +} + + +/* ============================================================ + 20. LOADING SPINNER + ============================================================ */ +.spinner { + display: inline-block; + width: 24px; + height: 24px; + border: 2.5px solid var(--color-border); + border-top-color: var(--color-blue); + border-radius: var(--radius-full); + animation: spin 650ms linear infinite; + flex-shrink: 0; +} + +.spinner--sm { + width: 16px; + height: 16px; + border-width: 2px; +} + +.spinner--lg { + width: 40px; + height: 40px; + border-width: 3.5px; +} + +.spinner--white { + border-color: rgba(255,255,255,0.3); + border-top-color: #ffffff; +} + +.spinner--success { + border-color: rgba(38, 194, 129, 0.2); + border-top-color: var(--color-success); +} + +/* Full-page overlay loader */ +.spinner-overlay { + position: fixed; + inset: 0; + z-index: var(--z-overlay); + background: rgba(255,255,255,0.80); + backdrop-filter: blur(4px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-4); + opacity: 0; + animation: fadeIn 0.2s ease 0.1s forwards; +} + +.spinner-overlay__label { + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-muted); +} + +/* Dots loader variant */ +.spinner-dots { + display: inline-flex; + align-items: center; + gap: var(--sp-2); +} + +.spinner-dots__dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--color-blue); + animation: dotBounce 1.2s ease-in-out infinite; +} + +.spinner-dots__dot:nth-child(2) { animation-delay: 0.15s; } +.spinner-dots__dot:nth-child(3) { animation-delay: 0.30s; } + + +/* ============================================================ + UTILITY CLASSES + ============================================================ */ + +/* Layout */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.items-start { align-items: flex-start; } +.justify-between { justify-content: space-between; } +.justify-center { justify-content: center; } +.gap-2 { gap: var(--sp-2); } +.gap-3 { gap: var(--sp-3); } +.gap-4 { gap: var(--sp-4); } +.gap-6 { gap: var(--sp-6); } +.flex-1 { flex: 1; } +.flex-wrap { flex-wrap: wrap; } +.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-4); } +.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); } + +/* Spacing */ +.mt-2 { margin-top: var(--sp-2); } +.mt-4 { margin-top: var(--sp-4); } +.mt-6 { margin-top: var(--sp-6); } +.mt-8 { margin-top: var(--sp-8); } +.mb-2 { margin-bottom: var(--sp-2); } +.mb-4 { margin-bottom: var(--sp-4); } +.mb-6 { margin-bottom: var(--sp-6); } +.p-4 { padding: var(--sp-4); } +.p-6 { padding: var(--sp-6); } + +/* Typography */ +.text-xs { font-size: var(--font-xs); } +.text-sm { font-size: var(--font-sm); } +.text-base { font-size: var(--font-base); } +.text-lg { font-size: var(--font-lg); } +.text-xl { font-size: var(--font-xl); } +.text-2xl { font-size: var(--font-2xl); } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-bold { font-weight: 700; } +.text-muted { color: var(--color-muted); } +.text-dark { color: var(--color-text-dark); } +.text-blue { color: var(--color-blue); } +.text-success { color: var(--color-success); } +.text-warning { color: var(--color-warning); } +.text-danger { color: var(--color-danger); } +.text-center { text-align: center; } +.text-right { text-align: right; } +.leading-relaxed { line-height: 1.7; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Visibility */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border-width: 0; +} + +.hidden { display: none !important; } + +/* Misc */ +.divider { + height: 1px; + background: var(--color-border); + width: 100%; +} + +.section { + padding: var(--sp-16) var(--sp-6); +} + +.container { + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px var(--sp-3); + border-radius: var(--radius-pill); + font-size: var(--font-xs); + font-weight: 600; + white-space: nowrap; +} + +.badge--blue { background: var(--color-blue-light); color: var(--color-blue); } +.badge--success { background: var(--color-success-bg); color: #16A366; } +.badge--warning { background: var(--color-warning-bg); color: #B97700; } +.badge--danger { background: var(--color-danger-bg); color: var(--color-danger); } +.badge--neutral { background: var(--color-surface); color: var(--color-muted); } + + +/* ============================================================ + CSS KEYFRAME ANIMATIONS + ============================================================ */ + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.85); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes popIn { + from { + opacity: 0; + transform: scale(0.6); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes ringPulse { + from { + opacity: 0.8; + transform: scale(0.85); + } + to { + opacity: 0; + transform: scale(1.4); + } +} + +@keyframes drawCheck { + to { stroke-dashoffset: 0; } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-6px); } + 40% { transform: translateX(6px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } +} + +@keyframes toastProgress { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} + +@keyframes dotBounce { + 0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; } + 40% { transform: scale(1.2); opacity: 1; } +} + +@keyframes sparkle { + 0%, 100% { transform: scale(1) rotate(0deg); } + 25% { transform: scale(1.15) rotate(10deg); } + 75% { transform: scale(0.9) rotate(-8deg); } +} + +@keyframes slideInRight { + from { opacity: 0; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes slideInLeft { + from { opacity: 0; transform: translateX(-24px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ============================================================ + AI TWO-STAGE RESULT CARD + ============================================================ */ + +/* Wrapper shown beneath the uploaded photo */ +.photo-meta { + margin-top: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.photo-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.photo-file-info { + font-size: var(--font-sm); + color: var(--color-muted); + font-weight: 500; +} + +/* EXIF / No-EXIF badges */ +.exif-badge { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border-radius: var(--radius-md); + font-size: var(--font-xs); + font-weight: 600; + animation: fadeInUp 0.3s ease; +} + +.exif-badge.verified { + background: var(--color-success-bg); + color: #166534; + border: 1px solid rgba(38,194,129,0.3); +} + +.exif-badge.manual { + background: var(--color-warning-bg); + color: #92400E; + border: 1px solid rgba(244,163,0,0.3); +} + +/* AI loading row */ +.ai-loading { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + background: var(--color-blue-subtle); + border: 1px solid var(--color-blue-light); + border-radius: var(--radius-md); + font-size: var(--font-sm); + color: var(--color-blue); + font-weight: 500; +} + +/* Main AI badge card */ +.ai-badge { + border-radius: var(--radius-lg); + border: 1.5px solid var(--color-border); + background: var(--color-card-bg); + padding: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-3); + animation: fadeInUp 0.35s ease; + box-shadow: var(--shadow-sm); +} + +/* Rejection state โ€” red border */ +.ai-badge--reject { + border-color: rgba(229, 62, 62, 0.35); + background: #FEF9F9; +} + +/* Success result โ€” subtle colored border */ +.ai-badge--result { + border-color: rgba(0, 97, 213, 0.2); +} + +.ai-badge--small { border-color: rgba(38, 194, 129, 0.3); } +.ai-badge--medium { border-color: rgba(244, 163, 0, 0.3); } +.ai-badge--large { border-color: rgba(229, 62, 62, 0.3); } + +/* Error state */ +.ai-badge--error { + flex-direction: row; + align-items: center; + border-color: rgba(229, 62, 62, 0.3); + background: var(--color-danger-bg); + padding: var(--sp-3) var(--sp-4); +} + +/* Stage row โ€” icon + text side by side */ +.ai-stage-row { + display: flex; + align-items: flex-start; + gap: var(--sp-3); +} + +.ai-stage-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ai-stage-icon--pass { + background: var(--color-success-bg); + color: var(--color-success); +} + +.ai-stage-icon--fail { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.ai-stage-body { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.ai-stage-label { + font-size: var(--font-xs); + font-weight: 600; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.ai-stage-result { + font-size: var(--font-sm); + font-weight: 700; + color: var(--color-text-dark); +} + +.ai-stage-result--pass { color: var(--color-success); } +.ai-stage-result--fail { color: var(--color-danger); } + +.ai-stage-sub { + font-size: var(--font-xs); + color: var(--color-muted); + line-height: 1.5; + margin-top: 2px; +} + +/* Divider between the two stages */ +.ai-divider { + height: 1px; + background: var(--color-border); + margin: var(--sp-1) 0; +} + +/* Size pill (Small / Medium / Large) */ +.ai-size-pill { + width: 64px; + height: 36px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-xs); + font-weight: 700; + flex-shrink: 0; + letter-spacing: 0.04em; +} + +/* Confidence score bars */ +.ai-scores { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding-top: var(--sp-2); + border-top: 1px solid var(--color-border); + margin-top: var(--sp-1); +} + +.ai-score-row { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.ai-score-row--winner .ai-score-label { + font-weight: 600; + color: var(--color-text-dark); +} + +.ai-score-label { + font-size: var(--font-xs); + font-weight: 500; + color: var(--color-muted); + width: 52px; + flex-shrink: 0; +} + +.ai-score-bar-wrap { + flex: 1; + height: 6px; + background: var(--color-border); + border-radius: var(--radius-full); + overflow: hidden; +} + +.ai-score-bar { + height: 100%; + border-radius: var(--radius-full); + transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.ai-score-pct { + font-size: var(--font-xs); + color: var(--color-muted); + width: 38px; + text-align: right; + flex-shrink: 0; +} + +/* Rejection notice at the bottom */ +.ai-reject-note { + font-size: var(--font-xs); + color: var(--color-danger); + font-weight: 500; + padding: var(--sp-2) var(--sp-3); + background: var(--color-danger-bg); + border-radius: var(--radius-md); + border: 1px solid rgba(229,62,62,0.15); +} + +/* Badge dot (used in error state) */ +.ai-badge-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + flex-shrink: 0; +} + +.ai-badge-label { + font-size: var(--font-xs); + font-weight: 600; + color: var(--color-muted); +} + +.ai-badge-result { + font-size: var(--font-xs); + color: var(--color-danger); +} + +/* โ”€โ”€ End of Design System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ diff --git a/track.html b/track.html new file mode 100644 index 0000000000000000000000000000000000000000..0b0b1e2c96916adb2ed046759afcc86b3a72866f --- /dev/null +++ b/track.html @@ -0,0 +1,1372 @@ + + + + + + Track Your Report โ€” City Pothole Reporting Portal + + + + + + + + + + + + + + +
+ +
+ +
+ + +
+
+
+ + Case Tracking +
+

Track Your Report

+

Enter your Case Reference Number to view the real-time status of your pothole report and repair progress.

+ +
+ +

Case ID was shown on your confirmation screen. Check your email if you provided one.

+
+
+
+ + +
+ + +
+
+ + + + +
+
No Case Loaded
+
Enter your Case Reference Number above to view the report status and timeline.
+
+
+ + + + + + + + + + +
+ + +
+
+
+
Repair Process
+

What happens to your report?

+
+
+
+
01
+
Submitted
+
Your photo and GPS coordinates are securely saved and timestamped in our system.
+
+
+
02
+
Processing
+
AI verifies the image and location. Staff may review the report for accuracy.
+
+
+
03
+
Engineer Review
+
A district engineer approves the case. High-severity cases are escalated for immediate dispatch.
+
+
+
04
+
Repair & Close
+
A repair crew is dispatched. Before & after photos are documented and the case is officially closed.
+
+
+
+
+ + + + +
+ + + + + diff --git a/workorder_template.html b/workorder_template.html new file mode 100644 index 0000000000000000000000000000000000000000..1c442ac63938bacdb1808b621180f15de86e23d6 --- /dev/null +++ b/workorder_template.html @@ -0,0 +1,146 @@ + + + + + Pothole Repair Work Order + + + + +
+
+
Pothole Repair Work Order
+
Department of Public Works โ€ข Operations Division
+
+
+
Form: DPW-PH-V2
+
Date Generated: __________________
+
+
+ +
1. Case Information (Pre-Filled by Box)
+
+
Case ID: ___________________________
+
Severity Level: ___________________________
+
Address: ___________________________
+
Ward / Area: ___________________________
+
+ +
2. Crew Assignment Details
+
+
Assigned Crew: ___________________________
+
Target Finish Date: ___________________________
+
+ +
3. Crew Completion Form (To be filled out by Crew Lead)
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
4. Authorizations & Signatures
+
+
+
+
Supervisor/Engineer Approval (Signature)
+
+
+
+
Crew Lead Completion (Signature)
+
+
+ +
+ Once signed, this document serves as the official proof of repair and will be archived inside the Case folder in Box. +
+ + +