Varun10000 Claude Fable 5 commited on
Commit
45e997f
Β·
0 Parent(s):

PotholeIQ: initial deployable build (Box + Supabase, HF Spaces Docker)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +10 -0
  2. .gitattributes +9 -0
  3. .gitignore +29 -0
  4. BACKEND_SETUP.md +95 -0
  5. CLIENT_DEMO_QUESTIONS.md +73 -0
  6. Dockerfile +43 -0
  7. LICENSE +31 -0
  8. Pothole Case Review Sheet (DocGen).docx +3 -0
  9. Pothole Closeout Job Sheet (DocGen Template).docx +3 -0
  10. Pothole Closeout Job Sheet (DocGen).docx +3 -0
  11. Pothole Closeout Job Sheet SAMPLE.docx +3 -0
  12. Pothole Repair Work Order (DocGen).docx +3 -0
  13. Pothole Repair Work Order.docx +3 -0
  14. Pothole Work Order (DocGen Template).docx +3 -0
  15. Pothole Work Order SAMPLE.docx +3 -0
  16. README.md +67 -0
  17. START_SERVER.bat +60 -0
  18. WORKFLOW_STORAGE_ONLY_GUIDE.md +260 -0
  19. WORKFLOW_V2_GUIDE.md +434 -0
  20. add-supervisor.js +38 -0
  21. ai/detect_pothole.py +85 -0
  22. ai/gen_closeout.py +149 -0
  23. ai/gen_doc.py +186 -0
  24. ai/gen_workorder.py +343 -0
  25. ai/pothole-yolov8.pt +3 -0
  26. ai/pothole_server.py +129 -0
  27. authority-routing.json +13 -0
  28. backend-api.js +293 -0
  29. box-build/BOX_APP_CONSOLE.md +123 -0
  30. box-build/BOX_APP_DASHBOARD.md +69 -0
  31. box-build/BOX_AUTOMATE_WORKFLOW.md +145 -0
  32. box-build/REVISED_WORKFLOW.md +149 -0
  33. box-build/WORKFLOW_ONE_BUILD.md +86 -0
  34. box-build/WORKFLOW_TWO_BUILD.md +65 -0
  35. box-build/add-breakdown-fields.sh +22 -0
  36. box-build/metadata-template.json +93 -0
  37. box-build/relay-flow-diagram.html +277 -0
  38. box-build/revised-workflow-diagram.html +206 -0
  39. box-build/seed-demo-cases.js +116 -0
  40. box-build/wf1-revised-diagram.html +119 -0
  41. box-folders.js +656 -0
  42. box-service.js +1318 -0
  43. box-upload.js +200 -0
  44. box-workflow.js +189 -0
  45. case-report.html +303 -0
  46. command-center.html +391 -0
  47. config-store.js +223 -0
  48. crew-closeout.html +602 -0
  49. dashcam-demo.html +931 -0
  50. dashcam-demo.js +989 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ data
3
+ .git
4
+ *.log
5
+ server.pid
6
+ __pycache__
7
+ *.pyc
8
+ .DS_Store
9
+ Dockerfile
10
+ .dockerignore
.gitattributes ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # HuggingFace requires ALL binary files to be stored via Git LFS.
2
+ *.pt filter=lfs diff=lfs merge=lfs -text
3
+ *.jpg filter=lfs diff=lfs merge=lfs -text
4
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
5
+ *.png filter=lfs diff=lfs merge=lfs -text
6
+ *.gif filter=lfs diff=lfs merge=lfs -text
7
+ *.webp filter=lfs diff=lfs merge=lfs -text
8
+ *.docx filter=lfs diff=lfs merge=lfs -text
9
+ *.pdf filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Local data + secrets β€” data/config.json holds Box credentials locally.
5
+ # On hosted deployments (HF Spaces) all of this comes from env-var secrets.
6
+ data/
7
+ start.bat
8
+ start.local.bat
9
+ .env
10
+ .env.*
11
+
12
+ # Logs / runtime
13
+ *.log
14
+ server.pid
15
+ *.stderr.log
16
+ *.stdout.log
17
+
18
+ # Python
19
+ __pycache__/
20
+ *.pyc
21
+
22
+ # OS / editor
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # Scratch
27
+ *.tmp
28
+ _*.png
29
+ _*.html
BACKEND_SETUP.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Setup
2
+
3
+ ## What This Backend Does
4
+
5
+ - Serves the app and API from one Node server
6
+ - Stores shared cases and workflow state in `data/cases.json`
7
+ - Accepts frontend case syncs and workflow actions
8
+ - Stores artifacts in Box while the backend/database stays the source of truth
9
+ - Optionally accepts legacy Box Automate updates at `POST /api/box/automate-update`
10
+
11
+ ## Start It
12
+
13
+ Run:
14
+
15
+ ```bat
16
+ START_SERVER.bat
17
+ ```
18
+
19
+ Preferred mode is Node.js. The server runs at:
20
+
21
+ ```txt
22
+ http://localhost:3030
23
+ ```
24
+
25
+ Health check:
26
+
27
+ ```txt
28
+ GET http://localhost:3030/api/health
29
+ ```
30
+
31
+ ## Optional Secret For Box Automate
32
+
33
+ Set this before starting the server if you want Box callback protection:
34
+
35
+ ```powershell
36
+ $env:BOX_AUTOMATE_SECRET="replace-with-a-secret"
37
+ node server.js
38
+ ```
39
+
40
+ Box Automate should then send the same value in:
41
+
42
+ ```txt
43
+ X-Box-Automate-Secret
44
+ ```
45
+
46
+ or as:
47
+
48
+ ```txt
49
+ ?secret=replace-with-a-secret
50
+ ```
51
+
52
+ ## Main API Endpoints
53
+
54
+ - `GET /api/health`
55
+ - `GET /api/cases`
56
+ - `GET /api/cases/:caseId`
57
+ - `POST /api/cases/upsert`
58
+ - `POST /api/cases/bulk-upsert`
59
+ - `POST /api/cases/:caseId/patch`
60
+ - `POST /api/workflow/supervisor-decision`
61
+ - `POST /api/workflow/dispatch-plan`
62
+ - `POST /api/workflow/crew-closeout`
63
+ - `POST /api/workflow/resolve-case`
64
+ - `POST /api/box/automate-update`
65
+
66
+ ## Box Automate Payload Shape
67
+
68
+ Send JSON like this from Box:
69
+
70
+ ```json
71
+ {
72
+ "caseId": "PHL-20260530-ABCDE1",
73
+ "workflowStage": "crew_dispatch",
74
+ "workflowStatus": "Assigned",
75
+ "workflowPriority": "high",
76
+ "workflowNextAction": "Dispatch crew",
77
+ "folderId": "1234567890",
78
+ "boxPath": "Potholes/Ward-29/Case-PHL-20260530-ABCDE1",
79
+ "assignedTo": "dispatch@city.gov",
80
+ "resolutionNotes": ""
81
+ }
82
+ ```
83
+
84
+ ## What You Still Need From Box
85
+
86
+ - A Box folder ID for the app root
87
+ - Supervisor email
88
+ - Dispatch or crew email/group
89
+ - A public URL for this backend when you want Box to call it from the cloud
90
+
91
+ For local testing with Box cloud callbacks, use a tunnel such as `ngrok` or `cloudflared`, then point Box Automate `HTTPS Request` to:
92
+
93
+ ```txt
94
+ https://your-public-url/api/box/automate-update
95
+ ```
CLIENT_DEMO_QUESTIONS.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PotholeIQ β€” Client Demo: Questions & Inputs to Collect
2
+
3
+ Use this during the session. ⭐ = blocker (get it or we can't deploy). πŸ“₯ = a file/credential to collect. βœ… = a decision to confirm.
4
+
5
+ ---
6
+
7
+ ## 1. Box Access & Authentication ⭐ (blocks everything β€” token expires every 60 min)
8
+ - ⭐ Who is your Box **enterprise admin**? Can they **authorize a custom app**? (needed for permanent auth)
9
+ - βœ… Auth method: **CCG** (admin authorizes once β†’ permanent, recommended) or **OAuth** (one user login β†’ 60-day)?
10
+ - πŸ“₯ Box **Enterprise ID** + the **folder** to use (intake folder, case-root folder).
11
+ - βœ… Which Box features are **enabled/licensed**: Relay (Automate), **Sign**, **Doc Gen**, **Forms**, **Apps** (custom dashboards)?
12
+ - πŸ“₯ A **service/bot Box account** to run the integration as (so it's not tied to a person)?
13
+
14
+ ## 2. City, GIS & Department
15
+ - βœ… Confirm **city / jurisdiction** (demo = Philadelphia).
16
+ - ⭐πŸ“₯ 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.)*
17
+ - πŸ“₯ Official **ward β†’ district β†’ maintenance-zone** mapping, if it exists.
18
+ - πŸ“₯ **Department name + logo / letterhead** for documents (work order, review sheet, closeout).
19
+
20
+ ## 3. Roles, People & Crew Dispatch
21
+ - πŸ“₯ **Reviewers / district engineers / supervisors** β€” names + **Box account emails**.
22
+ - βœ… Who **approves** a case, and who **dispatches** the crew?
23
+ - βœ… How is the **crew chosen per pothole**? (we built: reviewer types the crew email at approval β€” confirm)
24
+ - βœ… Are **crews Box users**, or external (email only β€” affects Sign vs notifications)?
25
+ - βœ… Who gets **notified** at each stage (new case / approved / dispatched / resolved)?
26
+
27
+ ## 4. Metadata Template & Fields
28
+ - βœ… Confirm the **case fields + dropdown values**. Known quirks to fix or keep:
29
+ - `weatherRisk` = **Low / Medium / Large** (no "High" β€” typo?)
30
+ - `duplicateStatus` = `new / "duplicate " / pending` (**trailing space** on "duplicate")
31
+ - `status` includes **`In progress`** (odd capitalization)
32
+ - βœ… Any **additional fields** they track today (asset ID, 311 reference, etc.)?
33
+
34
+ ## 5. Severity Scoring, SLA & Cost
35
+ - βœ… Agree with the **severity factors** (prior reports, traffic/road class, damage/size, weather) and **weights**?
36
+ - βœ… **SLA targets** by severity (e.g. Critical = N days)? β†’ drives "target completion" + days-to-resolve.
37
+ - πŸ“₯ **Actual repair-cost** figures by size? (we use researched estimates: Small ~$90–180, Medium ~$250–450, Large ~$450–800).
38
+
39
+ ## 6. Forms & Documents
40
+ - βœ… 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).
41
+ - βœ… Confirm **Work Order / Review Sheet / Closeout** layouts β€” fields to add/remove?
42
+ - βœ… **Box Sign** β€” how many signatures and who? (we set crew + supervisor).
43
+
44
+ ## 7. Duplicates, Data & Volume
45
+ - βœ… **Duplicate radius** (we use 80 m) β€” confirm.
46
+ - βœ… OK with an **external DB** (Supabase / PostGIS) for fast geo/duplicate search, or **all-in-Box** only?
47
+ - πŸ“₯ **Historical case data** to import?
48
+ - βœ… Expected **volume** (reports/day) β€” for sizing.
49
+
50
+ ## 8. AI / Detection
51
+ - βœ… Is the **open-source YOLO** pothole detector + size classification acceptable, or a specific accuracy bar / different model?
52
+ - βœ… Size thresholds (Small/Medium/Large) β€” tune to their definition?
53
+
54
+ ## 9. Deployment & Infra
55
+ - ⭐ Where will the **backend + portal + dashboard** be hosted? **Domain/URL**?
56
+ - ⭐ **HTTPS + public endpoint** for Box callbacks/forms β€” who provides it?
57
+ - βœ… Weather: use **our OpenWeather key** or **theirs** (their account/limits)?
58
+ - βœ… Who can access the **supervisor dashboard** (login list)?
59
+
60
+ ## 10. The Big Decision β€” Architecture
61
+ - βœ… Which model do they want?
62
+ 1. **Fully inside Box** (Relay + Sign + Doc Gen + Apps)
63
+ 2. **Box as storage only** (our portal + backend do the logic)
64
+ 3. **Hybrid (recommended)** β€” backend does geo/scoring/duplicate/weather + AI; Box does content, workflow, docs, sign, dashboard, governance
65
+
66
+ ---
67
+
68
+ ### Fast "must-leave-with" list (the 5 that unblock everything)
69
+ 1. ⭐ Admin to authorize the app (permanent auth) β€” or schedule it.
70
+ 2. ⭐ District + maintenance-zone GIS data (or confirm we derive from ward).
71
+ 3. ⭐ Hosting + HTTPS endpoint owner.
72
+ 4. πŸ“₯ Reviewer/supervisor Box emails + department name/logo.
73
+ 5. βœ… Architecture option (1 / 2 / 3).
Dockerfile ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PotholeIQ β€” Node backend + Python YOLO classifier + Chromium PDF rendering.
2
+ # Built for HuggingFace Spaces (Docker SDK, app_port 7860) but runs anywhere.
3
+ FROM node:20-slim
4
+
5
+ # System deps:
6
+ # - python3 + pip -> YOLOv8 classifier (ai/pothole_server.py) + docx generation
7
+ # - chromium -> before/after PDF rendering (htmlToPdf)
8
+ # - libgl1 / libglib2.0 -> OpenCV runtime used by ultralytics
9
+ # - fonts-liberation -> readable PDF text
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ python3 python3-pip \
12
+ chromium fonts-liberation \
13
+ libgl1 libglib2.0-0 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Python deps. CPU-only torch first (much smaller than the default CUDA build).
17
+ COPY requirements.txt /tmp/requirements.txt
18
+ RUN pip3 install --no-cache-dir --break-system-packages \
19
+ torch torchvision --index-url https://download.pytorch.org/whl/cpu \
20
+ && pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt
21
+
22
+ WORKDIR /app
23
+
24
+ # Node deps (cached layer)
25
+ COPY package.json package-lock.json ./
26
+ RUN npm ci --omit=dev
27
+
28
+ # App code (see .dockerignore for exclusions β€” no secrets, no local data)
29
+ COPY . .
30
+
31
+ # Runtime env. PORT 7860 is what HuggingFace Spaces routes to.
32
+ ENV PORT=7860 \
33
+ HOST=0.0.0.0 \
34
+ PYTHON_BIN=python3 \
35
+ CHROME_PATH=/usr/bin/chromium \
36
+ YOLO_CONFIG_DIR=/tmp/ultralytics
37
+
38
+ # HF Spaces runs the container as an arbitrary non-root user; make the app dir
39
+ # writable for the ephemeral data store (data/cases.json etc.).
40
+ RUN mkdir -p /app/data && chmod -R 777 /app/data && chmod 1777 /tmp
41
+
42
+ EXPOSE 7860
43
+ CMD ["node", "server.js"]
LICENSE ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CONFIDENTIAL & PROPRIETARY LICENSE AGREEMENT
2
+
3
+ Copyright (c) 2026 Varun. All rights reserved.
4
+
5
+ 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.
6
+
7
+ Dissemination of this information or reproduction of this material is strictly forbidden unless prior written permission is obtained from the Copyright Holder.
8
+
9
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION:
10
+
11
+ 1. DEFINITIONS:
12
+ "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.
13
+ "Licensor" or "Copyright Holder" shall mean Varun, the sole owner and author of the Software.
14
+ "You" (or "Your") shall mean any individual or Legal Entity exercising permissions granted by this License.
15
+
16
+ 2. OWNERSHIP AND INTELLECTUAL PROPERTY:
17
+ 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.
18
+
19
+ 3. RESTRICTIONS:
20
+ You may NOT:
21
+ 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.
22
+ b. Modify, translate, adapt, merge, make derivative works of, disassemble, decompile, decrypt, or reverse engineer any part of the Software.
23
+ c. Remove, alter, or obscure any copyright, trademark, or other proprietary rights notices contained in or on the Software.
24
+ d. Sublicense, rent, lease, lend, sell, or distribute the Software or any portion thereof to any third party.
25
+ e. Use the Software for any unauthorized, unlawful, or competitive commercial purpose.
26
+
27
+ 4. NO WARRANTY:
28
+ 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.
29
+
30
+ 5. TERMINATION:
31
+ 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.
Pothole Case Review Sheet (DocGen).docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e2eae884c5096fb92b1d34307234a69c19a00574737620161f8f4d5da9e43c75
3
+ size 37165
Pothole Closeout Job Sheet (DocGen Template).docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1a0710f1d930941db1d3829ba41d0e921e00e9b7c852d35d5f7f5f56de182427
3
+ size 37725
Pothole Closeout Job Sheet (DocGen).docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:18dda542dafb4a50b2ed2f35b3759e4a49ee7ec8495379ecc7008581db397a12
3
+ size 17378
Pothole Closeout Job Sheet SAMPLE.docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7e08e6fba69dba8d55e447cab92f8e2f1b8719076ea798b03b14e0da23af458f
3
+ size 165704
Pothole Repair Work Order (DocGen).docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:624994a7611c4f44265b3c1b3f11ead562a97f6c5c2ba5eb728a0e3b8bce0d90
3
+ size 17356
Pothole Repair Work Order.docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:111c6b2732a42a2a855360391ffde13ba8db380e20202dffa74ad1ff7c2b77b1
3
+ size 17188
Pothole Work Order (DocGen Template).docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:46b6ee4a88ad6ba10f5bd7238fe653987fc457a9c8451a85db019b1fcb711389
3
+ size 39063
Pothole Work Order SAMPLE.docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:82c7a27628716fc0e93f01260e8223c1c020d82cf5fb8e798f4ac9f8c2010965
3
+ size 195767
README.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PotholeIQ
3
+ emoji: πŸ›£οΈ
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # PotholeIQ β€” Pothole Management on the Box Platform
12
+
13
+ One workflow from citizen intake to verified repair: AI photo classification
14
+ (YOLOv8), severity scoring, GIS mapping, supervisor review, crew dispatch,
15
+ digital closeout, and an auditable **before/after report** β€” with **Box** as the
16
+ content system of record and **Supabase** as the data mirror.
17
+
18
+ ## Pages
19
+
20
+ | Page | Purpose |
21
+ |---|---|
22
+ | `/index.html` | Citizen submission portal (photo + GPS, AI classification) |
23
+ | `/command-center.html` | Supervisor Command Center (KPIs, map, approve/dispatch/resolve) |
24
+ | `/crew-closeout.html?case=ID` | Standalone crew closeout form (locked to one case) |
25
+ | `/case-report.html?case=ID` | Printable before/after report |
26
+ | `/track.html` | Citizen case tracking |
27
+
28
+ ## Configuration (Space secrets β†’ env vars)
29
+
30
+ **Box (required)**
31
+
32
+ | Secret | Example / note |
33
+ |---|---|
34
+ | `BOX_AUTH_MODE` | `client_credentials` |
35
+ | `BOX_CLIENT_ID` | Box app client id |
36
+ | `BOX_CLIENT_SECRET` | Box app client secret |
37
+ | `BOX_SUBJECT_TYPE` | `enterprise` |
38
+ | `BOX_SUBJECT_ID` | Box enterprise id |
39
+ | `BOX_INTAKE_FOLDER_ID` | Intake folder id |
40
+ | `BOX_CASE_ROOT_FOLDER_ID` | Case root folder id (often same as intake) |
41
+ | `BOX_METADATA_SCOPE` | `enterprise` |
42
+ | `BOX_METADATA_TEMPLATE_KEY` | `potholeCase` |
43
+
44
+ **Supabase (recommended β€” data mirror + supervisor login)**
45
+
46
+ | Secret | Note |
47
+ |---|---|
48
+ | `SUPABASE_URL` | `https://<ref>.supabase.co` |
49
+ | `SUPABASE_SECRET_KEY` | service-role key (server-side only) |
50
+ | `DASHBOARD_AUTH` | `true` to require supervisor login on dashboard APIs |
51
+
52
+ **Optional**
53
+
54
+ | Secret | Note |
55
+ |---|---|
56
+ | `CLOSEOUT_NOTIFY_RECIPIENTS` | Comma-separated default recipients for before/after reports |
57
+ | `BOX_AUTOMATE_SECRET` | Shared secret for Box Automate β†’ `/api/box/automate-update` |
58
+ | `DATA_DIR` | Set to `/data/potholeiq` if HF persistent storage is enabled |
59
+ | `BOX_CASES_TTL_MS` | Box case-list cache TTL (default 45000) |
60
+
61
+ ## Architecture notes
62
+
63
+ - **Box = source of truth for content**: photos, sidecars, generated documents,
64
+ before/after PDFs (in `Closeout Reports`), metadata (`potholeCase` template).
65
+ - **Supabase = data mirror** (`db/schema.sql`, run once in the Supabase SQL editor).
66
+ - The local `data/` store is a working cache only β€” safe to lose on restarts.
67
+ - First AI classification after a cold start takes ~30–60 s (model load).
START_SERVER.bat ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ title Pothole Portal - Local Server
3
+ color 0A
4
+ echo.
5
+ echo ============================================
6
+ echo POTHOLE CITIZEN PORTAL - LOCAL SERVER
7
+ echo ============================================
8
+ echo.
9
+ echo Starting server on port 3030...
10
+ echo.
11
+
12
+ REM Prefer the Node backend because it serves both the app and the API
13
+ node --version >nul 2>&1
14
+ if %errorlevel% == 0 (
15
+ echo [Node.js] Full app + API server running at:
16
+ echo.
17
+ echo http://localhost:3030
18
+ echo.
19
+ echo This includes the Box workflow backend endpoints.
20
+ echo Press Ctrl+C to stop.
21
+ echo.
22
+ node server.js
23
+ goto end
24
+ )
25
+
26
+ REM Fallback to Python static hosting if Node is unavailable
27
+ python --version >nul 2>&1
28
+ if %errorlevel% == 0 (
29
+ echo [Python] Static server running at:
30
+ echo.
31
+ echo http://localhost:3030
32
+ echo.
33
+ echo WARNING: API backend endpoints will NOT be available in this mode.
34
+ echo.
35
+ python -m http.server 3030
36
+ goto end
37
+ )
38
+
39
+ REM Try Python3
40
+ python3 --version >nul 2>&1
41
+ if %errorlevel% == 0 (
42
+ echo [Python3] Static server running at:
43
+ echo.
44
+ echo http://localhost:3030
45
+ echo.
46
+ echo WARNING: API backend endpoints will NOT be available in this mode.
47
+ echo.
48
+ python3 -m http.server 3030
49
+ goto end
50
+ )
51
+
52
+ echo ERROR: Neither Node.js nor Python was found.
53
+ echo.
54
+ echo Please install Node.js from https://nodejs.org
55
+ echo or Python from https://python.org
56
+ echo then run this file again.
57
+ echo.
58
+
59
+ :end
60
+ pause
WORKFLOW_STORAGE_ONLY_GUIDE.md ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pothole Workflow: Box Storage Only
2
+
3
+ This is the recommended workflow if Box is used only as the shared content repository.
4
+
5
+ In this model:
6
+
7
+ - Box stores photos, JSON files, case folders, review summaries, closeout proof, and reports
8
+ - The app and backend handle review, approvals, dispatch, authority notification, crew forms, and reporting logic
9
+ - GIS, weather, duplicate checks, and severity scoring all happen outside Box
10
+
11
+ ## 1. Architecture
12
+
13
+ Use this split everywhere:
14
+
15
+ - `Box`
16
+ - intake photo storage
17
+ - intake JSON storage
18
+ - organized case folders
19
+ - enriched JSON files
20
+ - human-readable review summaries
21
+ - after-photos and repair proof
22
+ - generated reports
23
+
24
+ - `Backend / app`
25
+ - GIS mapping
26
+ - reverse geocoding
27
+ - duplicate detection
28
+ - weather enrichment
29
+ - severity scoring
30
+ - supervisor review decisions
31
+ - authority email
32
+ - crew assignment and routing
33
+ - crew completion form
34
+ - before/after report generation
35
+
36
+ ## 2. End-to-End Flow
37
+
38
+ ### Stage 1. Citizen Intake
39
+
40
+ Owner:
41
+ - citizen portal
42
+
43
+ System actions:
44
+ - collect photo
45
+ - collect GPS or manual pin
46
+ - reverse geocode address
47
+ - capture AI review status and pothole size
48
+ - upload photo to Box `Incoming Detections`
49
+ - upload JSON sidecar to Box `Incoming Detections`
50
+ - upsert the same case into the backend
51
+
52
+ Status:
53
+ - `Submitted`
54
+
55
+ Main files:
56
+ - `index.html`
57
+ - `box-upload.js`
58
+ - `box-service.js`
59
+
60
+ ### Stage 2. Ops Enrichment
61
+
62
+ Owner:
63
+ - ops review console
64
+
65
+ System actions:
66
+ - load submitted case from backend
67
+ - resolve official GIS ward, district, and maintenance zone
68
+ - check duplicates by GPS radius
69
+ - pull weather risk
70
+ - calculate severity score and severity level
71
+ - organize the case into the right Box folder
72
+ - write enriched JSON into the case folder
73
+ - write a readable review summary into the case folder
74
+ - write Box metadata for reporting and indexing
75
+
76
+ Status:
77
+ - `Under Review`
78
+
79
+ Main files:
80
+ - `ops-review.html`
81
+ - `enrichment.js`
82
+ - `gis.js`
83
+ - `box-folders.js`
84
+
85
+ ### Stage 3. Supervisor Review
86
+
87
+ Owner:
88
+ - supervisor using our app, not Box
89
+
90
+ System actions:
91
+ - review image
92
+ - review readable summary
93
+ - review severity, duplicate status, weather, ward, district, and zone
94
+ - choose:
95
+ - approve
96
+ - reject
97
+ - merge duplicate
98
+
99
+ Status:
100
+ - `Under Review` until approved or rejected
101
+
102
+ Suggested result fields:
103
+ - `supervisorDecision`
104
+ - `supervisorReason`
105
+ - `supervisorAt`
106
+ - `supervisorBy`
107
+
108
+ ### Stage 4. Authority Notification
109
+
110
+ Owner:
111
+ - backend
112
+
113
+ System actions:
114
+ - only for approved primary cases
115
+ - look up authority by district or maintenance zone
116
+ - send authority email with case details
117
+ - include Box folder path or Box link
118
+
119
+ Status:
120
+ - stays `Under Review` until dispatch is created
121
+
122
+ ### Stage 5. Work Order and Dispatch
123
+
124
+ Owner:
125
+ - dispatcher or backend
126
+
127
+ System actions:
128
+ - create work order outside Box
129
+ - assign crew
130
+ - generate route plan
131
+ - save dispatch artifact to Box
132
+ - patch backend case with crew assignment
133
+
134
+ Status:
135
+ - `Assigned`
136
+
137
+ Main files:
138
+ - `deploy-crew.html`
139
+
140
+ ### Stage 6. Crew Work Start
141
+
142
+ Owner:
143
+ - field crew
144
+
145
+ System actions:
146
+ - crew accepts assignment
147
+ - mark start time
148
+ - mark assigned crew and route stop
149
+
150
+ Status:
151
+ - `In Progress`
152
+
153
+ ### Stage 7. Crew Completion
154
+
155
+ Owner:
156
+ - field crew
157
+
158
+ System actions:
159
+ - submit after-photo
160
+ - submit materials used
161
+ - submit labor hours
162
+ - submit repair notes
163
+ - submit signature if required
164
+ - upload closeout files to Box
165
+
166
+ Status:
167
+ - still `In Progress` until verified
168
+
169
+ Suggested fields:
170
+ - `afterPhotoFileId`
171
+ - `materialsUsed`
172
+ - `laborHours`
173
+ - `resolutionNotes`
174
+ - `crewSignature`
175
+ - `completedAt`
176
+
177
+ ### Stage 8. Supervisor Closeout
178
+
179
+ Owner:
180
+ - supervisor
181
+
182
+ System actions:
183
+ - verify repair proof
184
+ - verify after-photo
185
+ - mark case resolved
186
+ - optionally notify citizen
187
+
188
+ Status:
189
+ - `Resolved`
190
+
191
+ ### Stage 9. Reporting
192
+
193
+ Owner:
194
+ - backend
195
+
196
+ System actions:
197
+ - generate before/after report
198
+ - generate daily and weekly summaries
199
+ - upload reports to Box `Reports`
200
+
201
+ Outputs:
202
+ - per-case audit report
203
+ - daily summary report
204
+ - weekly summary report
205
+
206
+ ## 3. Box Folder Responsibilities
207
+
208
+ Use Box for these artifacts only:
209
+
210
+ - original photo
211
+ - original JSON sidecar
212
+ - enriched JSON
213
+ - readable review summary
214
+ - before and after photos
215
+ - work order file
216
+ - dispatch plan export
217
+ - audit report
218
+ - daily and weekly reports
219
+
220
+ ## 4. JSON Fields To Keep Standard
221
+
222
+ These are the business fields we should keep stable:
223
+
224
+ - `caseId`
225
+ - `submittedAt`
226
+ - `address`
227
+ - `areaLabel`
228
+ - `location.lat`
229
+ - `location.lng`
230
+ - `location.source`
231
+ - `ward`
232
+ - `district`
233
+ - `maintenanceZone`
234
+ - `duplicateStatus`
235
+ - `weatherStatus`
236
+ - `potholeSize`
237
+ - `aiResult`
238
+ - `aiPrediction`
239
+ - `aiReviewStatus`
240
+ - `aiReviewMessage`
241
+
242
+ ## 5. Current Best Operating Order
243
+
244
+ 1. Citizen submits report
245
+ 2. Box stores photo and intake JSON
246
+ 3. Ops review enriches the case outside Box
247
+ 4. Box stores the enriched JSON and review summary
248
+ 5. Supervisor reviews in our app
249
+ 6. Backend sends authority notification
250
+ 7. Dispatcher assigns crew and route
251
+ 8. Crew uploads closeout proof
252
+ 9. Backend generates reports into Box
253
+
254
+ ## 6. Immediate Next Build Order
255
+
256
+ 1. Supervisor decision outside Box
257
+ 2. Authority email automation outside Box
258
+ 3. Crew completion form outside Box
259
+ 4. Before/after report generator outside Box
260
+ 5. Save all artifacts back into Box
WORKFLOW_V2_GUIDE.md ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pothole Operations Workflow V2
2
+
3
+ This is the recommended operating workflow for the current `pothole box` app.
4
+
5
+ It is designed around the code that already exists:
6
+
7
+ - Citizen intake: `index.html`, `app.js`, `box-upload.js`
8
+ - Ops review and enrichment: `ops-review.html`, `enrichment.js`, `box-folders.js`
9
+ - Shared case state: `server.js`, `backend-api.js`
10
+ - Crew dispatch: `deploy-crew.html`
11
+ - Citizen tracking: `track.html`
12
+ - GIS routing: `gis.js`
13
+ - Weather enrichment: `enrichment.js`
14
+
15
+ ## 1. Core Rules
16
+
17
+ Use these rules everywhere.
18
+
19
+ 1. Keep only these public case statuses:
20
+ `Submitted`, `Under Review`, `Assigned`, `In Progress`, `Resolved`, `Rejected`
21
+
22
+ 2. Box is the file and metadata system:
23
+ photos, JSON sidecars, case folders, enriched files, dispatch artifacts, completion proof
24
+
25
+ 3. The backend is the shared live case state:
26
+ every screen should read and write through the backend, not only `localStorage`
27
+
28
+ 4. GIS, weather, AI, and duplicate detection are decision inputs:
29
+ they help routing and prioritization, but they do not auto-dispatch by themselves
30
+
31
+ 5. Authority email must not be sent on raw citizen submission:
32
+ send it only after supervisor approval and duplicate review
33
+
34
+ 6. Duplicate child cases must not create extra crew jobs:
35
+ one pothole cluster should have one active operational case owner
36
+
37
+ 7. A case is not truly closed until repair proof exists:
38
+ after-photo, crew notes, completion timestamp, and supervisor confirmation
39
+
40
+ ## 2. Recommended End-to-End Workflow
41
+
42
+ ### Stage 1. Citizen Submission
43
+
44
+ Owner: citizen portal
45
+
46
+ Trigger:
47
+ - User submits photo, location, and optional contact
48
+
49
+ System actions:
50
+ - Run image and road checks
51
+ - Capture GPS and reverse geocode
52
+ - Resolve GIS mapping if configured
53
+ - Upload photo and metadata sidecar to Box
54
+ - Create initial workflow metadata
55
+ - Upsert case to backend
56
+
57
+ Status after this stage:
58
+ - `Submitted`
59
+
60
+ Files involved:
61
+ - `index.html`
62
+ - `app.js`
63
+ - `box-upload.js`
64
+
65
+ ### Stage 2. Ops Enrichment
66
+
67
+ Owner: ops review console
68
+
69
+ Trigger:
70
+ - New `Submitted` case appears in ops queue
71
+
72
+ System actions:
73
+ - Run severity enrichment
74
+ - Check duplicates by GPS radius
75
+ - Pull weather risk
76
+ - Resolve district, ward, and maintenance zone
77
+ - Organize the case into the correct Box folder
78
+ - Write enriched metadata back to Box and backend
79
+
80
+ Status after this stage:
81
+ - `Under Review`
82
+
83
+ Files involved:
84
+ - `ops-review.html`
85
+ - `enrichment.js`
86
+ - `box-folders.js`
87
+ - `gis.js`
88
+
89
+ ### Stage 3. Supervisor Decision
90
+
91
+ Owner: supervisor
92
+
93
+ Supervisor can choose one of three outcomes:
94
+
95
+ 1. Reject
96
+ - Use when the report is invalid, unsafe, or not a pothole
97
+ - Final status: `Rejected`
98
+
99
+ 2. Merge into an existing active case
100
+ - Use when duplicate detection is confirmed
101
+ - Do not create a new crew assignment
102
+ - Do not send a new authority email
103
+ - Keep one primary operational case for the cluster
104
+ - Child case should stay linked to the parent in metadata
105
+
106
+ 3. Approve for action
107
+ - Use when it is a valid pothole and should move to operations
108
+ - Continue to authority notification and dispatch
109
+
110
+ Status after approval:
111
+ - Keep `Under Review` until authority notification and dispatch preparation are done
112
+
113
+ Files involved:
114
+ - `ops-review.html`
115
+
116
+ ### Stage 4. Authority Notification
117
+
118
+ Owner: backend automation
119
+
120
+ Trigger:
121
+ - Supervisor approved the case
122
+ - Case is the primary case in its duplicate cluster
123
+ - District and maintenance zone are known
124
+
125
+ Notification policy:
126
+
127
+ 1. Critical severity
128
+ - Send immediately to the concerned authority and dispatch lead
129
+
130
+ 2. High, Medium, Low severity
131
+ - Send in district or zone digest batches every 30 to 60 minutes
132
+
133
+ Email must include:
134
+ - Case ID
135
+ - Address
136
+ - GIS district, ward, maintenance zone
137
+ - Severity score and level
138
+ - Duplicate count and linked cases
139
+ - Reporter count if relevant
140
+ - Box folder path or Box folder link
141
+ - Recommended next action
142
+
143
+ Important guardrail:
144
+ - Do not send one email per duplicate report
145
+
146
+ Status after this stage:
147
+ - Still `Under Review` until a crew is actually assigned
148
+
149
+ Build needed:
150
+ - Add backend email service and routing table
151
+
152
+ ### Stage 5. Crew Dispatch
153
+
154
+ Owner: dispatcher or supervisor
155
+
156
+ Trigger:
157
+ - Approved primary case is ready for field work
158
+
159
+ System actions:
160
+ - Group approved active potholes by ward or zone
161
+ - Generate route plan
162
+ - Assign crew
163
+ - Save dispatch plan
164
+ - Push assignment to backend
165
+
166
+ Status after this stage:
167
+ - `Assigned`
168
+
169
+ Files involved:
170
+ - `deploy-crew.html`
171
+
172
+ ### Stage 6. Crew Work Start
173
+
174
+ Owner: field crew
175
+
176
+ Trigger:
177
+ - Crew accepts the assignment or starts work on site
178
+
179
+ System actions:
180
+ - Mark case start time
181
+ - Mark assigned crew
182
+ - Optionally capture ETA or on-site note
183
+
184
+ Status after this stage:
185
+ - `In Progress`
186
+
187
+ Build needed:
188
+ - Crew action form or simple crew mobile screen
189
+
190
+ ### Stage 7. Crew Completion Form
191
+
192
+ Owner: field crew
193
+
194
+ Required fields:
195
+ - Case ID
196
+ - Crew name
197
+ - Completion timestamp
198
+ - After-photo
199
+ - Repair notes
200
+ - Material used or fill quantity
201
+ - Could not complete reason, if blocked
202
+
203
+ System actions:
204
+ - Upload completion proof to Box
205
+ - Patch backend case
206
+ - Flag case for supervisor closeout
207
+
208
+ Status after this stage:
209
+ - Keep `In Progress` until supervisor verifies proof
210
+
211
+ Build needed:
212
+ - New crew completion form page tied to Box and backend
213
+
214
+ ### Stage 8. Supervisor Closeout
215
+
216
+ Owner: supervisor
217
+
218
+ Trigger:
219
+ - Crew completion proof received
220
+
221
+ System actions:
222
+ - Review after-photo and notes
223
+ - Confirm repair is complete
224
+ - Mark resolved timestamp
225
+ - Optionally notify citizen if contact was provided
226
+
227
+ Status after this stage:
228
+ - `Resolved`
229
+
230
+ ### Stage 9. Reporting
231
+
232
+ Owner: backend automation
233
+
234
+ Trigger:
235
+ - Scheduled daily and weekly jobs
236
+
237
+ Reports to generate:
238
+ - New cases by district and ward
239
+ - Duplicate clusters
240
+ - Average approval time
241
+ - Average assignment time
242
+ - Average repair completion time
243
+ - Open active cases by zone
244
+ - Rejected reports
245
+ - Weather-linked spikes
246
+ - Crew productivity summary
247
+
248
+ Output:
249
+ - Save CSV or PDF report into a Box reports folder
250
+ - Email summary to management if needed
251
+
252
+ Build needed:
253
+ - Scheduled report generator on backend
254
+
255
+ ## 3. Status Rules
256
+
257
+ Use this as the single status policy.
258
+
259
+ ### `Submitted`
260
+
261
+ Meaning:
262
+ - Citizen report received
263
+ - Photo and metadata saved
264
+ - Waiting for ops enrichment
265
+
266
+ ### `Under Review`
267
+
268
+ Meaning:
269
+ - Ops is validating the report
270
+ - Duplicate and routing checks are in progress
271
+ - Supervisor approval is pending or just completed but dispatch is not done yet
272
+
273
+ ### `Assigned`
274
+
275
+ Meaning:
276
+ - A crew has been assigned
277
+ - Dispatch plan exists
278
+
279
+ ### `In Progress`
280
+
281
+ Meaning:
282
+ - Crew is actively working or has started the job
283
+
284
+ ### `Resolved`
285
+
286
+ Meaning:
287
+ - Repair proof exists
288
+ - Supervisor or workflow confirmed the fix
289
+
290
+ ### `Rejected`
291
+
292
+ Meaning:
293
+ - The report is invalid or closed without field action
294
+
295
+ ## 4. What Should Live Where
296
+
297
+ ### Keep in Box
298
+
299
+ - Original photo
300
+ - Original metadata sidecar
301
+ - Enriched metadata file
302
+ - Case folder metadata
303
+ - Dispatch sheet
304
+ - After-photo and repair proof
305
+ - Final reports
306
+
307
+ ### Keep in Backend
308
+
309
+ - Current status
310
+ - Current assignee
311
+ - Workflow timestamps
312
+ - Parent and child duplicate links
313
+ - Authority email audit trail
314
+ - Crew start and completion data
315
+
316
+ ### Keep as Integrations
317
+
318
+ - GIS boundary lookup
319
+ - Weather forecast lookup
320
+ - Email delivery
321
+
322
+ ## 5. Safe Rollout Order
323
+
324
+ This is the best order to implement without breaking the app.
325
+
326
+ ### Step 1. Stabilize shared case state
327
+
328
+ Do first.
329
+
330
+ Goal:
331
+ - Make the backend the live source of truth across intake, ops review, dispatch, and tracking
332
+
333
+ Why:
334
+ - Right now some workflow still depends on `localStorage`, which is risky for multi-user operations
335
+
336
+ ### Step 2. Lock the status model
337
+
338
+ Do second.
339
+
340
+ Goal:
341
+ - Use only the six public statuses already supported by the tracking UI
342
+
343
+ Why:
344
+ - Avoid adding new public statuses until the tracking and review UIs are updated together
345
+
346
+ ### Step 3. Add authority routing table
347
+
348
+ Do third.
349
+
350
+ Goal:
351
+ - Map each district or maintenance zone to the correct authority email list
352
+
353
+ Needed fields:
354
+ - `district`
355
+ - `ward`
356
+ - `maintenanceZone`
357
+ - `authorityEmails`
358
+ - `dispatchEmails`
359
+
360
+ ### Step 4. Add real authority email automation
361
+
362
+ Do fourth.
363
+
364
+ Goal:
365
+ - Send approved active pothole notifications from the backend
366
+
367
+ Rules:
368
+ - Immediate for `Critical`
369
+ - Batched digest for the rest
370
+ - No email for duplicate child cases
371
+
372
+ ### Step 5. Add crew completion form
373
+
374
+ Do fifth.
375
+
376
+ Goal:
377
+ - Give crews a proper way to upload after-photos and repair notes
378
+
379
+ ### Step 6. Add closeout verification
380
+
381
+ Do sixth.
382
+
383
+ Goal:
384
+ - Resolve only after proof is reviewed
385
+
386
+ ### Step 7. Add scheduled reporting
387
+
388
+ Do seventh.
389
+
390
+ Goal:
391
+ - Auto-generate daily and weekly reports into Box
392
+
393
+ ### Step 8. Move secrets off the browser
394
+
395
+ Do this before production use.
396
+
397
+ Goal:
398
+ - Remove Box and weather secrets from browser storage
399
+ - Keep them on the backend instead
400
+
401
+ ## 6. Immediate Next Recommendation
402
+
403
+ If you want the cleanest next build step, do this first:
404
+
405
+ 1. Make backend case sync the source of truth
406
+ 2. Add authority email automation on approved primary cases
407
+ 3. Add crew completion form
408
+ 4. Add daily and weekly report generator
409
+
410
+ That order gives you the biggest operational improvement with the least confusion.
411
+
412
+ ## 7. Short Version
413
+
414
+ The correct workflow is:
415
+
416
+ 1. Citizen submits report
417
+ 2. Ops enriches and checks duplicates
418
+ 3. Supervisor rejects, merges, or approves
419
+ 4. Backend notifies the correct authority
420
+ 5. Dispatcher assigns crew
421
+ 6. Crew marks work in progress
422
+ 7. Crew submits completion proof
423
+ 8. Supervisor closes the case
424
+ 9. Backend generates reports
425
+
426
+ The most important correction is this:
427
+
428
+ Do not treat Box alone as the whole system.
429
+
430
+ Use:
431
+ - Box for files and workflow metadata
432
+ - Backend for live state and automation
433
+ - GIS and weather as decision inputs
434
+ - Email and reports as backend-driven automations
add-supervisor.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Add (or update) a supervisor who can access the Command Center.
3
+ *
4
+ * Usage:
5
+ * SUPABASE_URL=... SUPABASE_SECRET_KEY=sb_secret_... \
6
+ * node add-supervisor.js <email> <password>
7
+ *
8
+ * Creates an auto-confirmed Supabase user with app_metadata.role = "supervisor"
9
+ * (the role the backend + login page require). Re-running for an existing email
10
+ * resets the password and ensures the role.
11
+ */
12
+ 'use strict';
13
+ const { createClient } = require('@supabase/supabase-js');
14
+
15
+ const URL = process.env.SUPABASE_URL;
16
+ const SECRET = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_KEY;
17
+ const [email, password] = process.argv.slice(2);
18
+
19
+ if (!URL || !SECRET) { console.error('Set SUPABASE_URL and SUPABASE_SECRET_KEY env vars.'); process.exit(1); }
20
+ if (!email || !password) { console.error('Usage: node add-supervisor.js <email> <password>'); process.exit(1); }
21
+
22
+ (async () => {
23
+ const admin = createClient(URL, SECRET, { auth: { persistSession: false } });
24
+ const { error } = await admin.auth.admin.createUser({
25
+ email, password, email_confirm: true, app_metadata: { role: 'supervisor' },
26
+ });
27
+ if (error && /already/i.test(error.message)) {
28
+ const list = await admin.auth.admin.listUsers();
29
+ const u = list.data.users.find((x) => x.email === email);
30
+ if (!u) { console.error('User exists but could not be located.'); process.exit(1); }
31
+ await admin.auth.admin.updateUserById(u.id, { password, app_metadata: { role: 'supervisor' } });
32
+ console.log('Updated supervisor:', email);
33
+ } else if (error) {
34
+ console.error('Error:', error.message); process.exit(1);
35
+ } else {
36
+ console.log('Created supervisor:', email);
37
+ }
38
+ })().catch((e) => { console.error('ERROR', e.message); process.exit(1); });
ai/detect_pothole.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Open-source pothole classifier β€” YOLOv8 (Ultralytics), runs locally, no API cost.
4
+ Model: peterhdd/pothole-detection-yolov8 (single "pothole" class).
5
+
6
+ Usage: python ai/detect_pothole.py <image_path>
7
+ Output: one line of JSON on stdout:
8
+ {"isPothole": bool, "confidence": float, "size": "Small|Medium|Large"|null,
9
+ "count": int, "largestAreaFraction": float, "boxes": [...], "model": "..."}
10
+ Errors are also returned as JSON: {"error": "..."}.
11
+ """
12
+ import sys
13
+ import os
14
+ import json
15
+
16
+ # Size thresholds: largest detection box area as a fraction of the whole frame.
17
+ # A single 2D photo has no true scale, so this is a heuristic proxy (tune as needed).
18
+ SIZE_LARGE = 0.12
19
+ SIZE_MEDIUM = 0.04
20
+ CONF_THRESHOLD = 0.25
21
+ MODEL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pothole-yolov8.pt")
22
+
23
+
24
+ def main():
25
+ if len(sys.argv) < 2:
26
+ print(json.dumps({"error": "usage: detect_pothole.py <image_path>"}))
27
+ return
28
+ image_path = sys.argv[1]
29
+ if not os.path.exists(image_path):
30
+ print(json.dumps({"error": "image not found: " + image_path}))
31
+ return
32
+ if not os.path.exists(MODEL_FILE):
33
+ print(json.dumps({"error": "model file missing: " + MODEL_FILE}))
34
+ return
35
+ try:
36
+ from ultralytics import YOLO
37
+ except Exception as e: # noqa: BLE001
38
+ print(json.dumps({"error": "ultralytics not installed: " + str(e)}))
39
+ return
40
+
41
+ try:
42
+ model = YOLO(MODEL_FILE)
43
+ result = model.predict(image_path, conf=CONF_THRESHOLD, verbose=False)[0]
44
+ h, w = result.orig_shape
45
+ frame_area = float(h * w) if h and w else 1.0
46
+
47
+ boxes = []
48
+ max_frac = 0.0
49
+ max_conf = 0.0
50
+ for b in result.boxes:
51
+ x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist())
52
+ conf = float(b.conf[0])
53
+ frac = ((x2 - x1) * (y2 - y1)) / frame_area
54
+ boxes.append({
55
+ "x1": round(x1), "y1": round(y1), "x2": round(x2), "y2": round(y2),
56
+ "conf": round(conf, 3), "areaFraction": round(frac, 4),
57
+ })
58
+ max_frac = max(max_frac, frac)
59
+ max_conf = max(max_conf, conf)
60
+
61
+ is_pothole = len(boxes) > 0
62
+ if not is_pothole:
63
+ size = None
64
+ elif max_frac >= SIZE_LARGE:
65
+ size = "Large"
66
+ elif max_frac >= SIZE_MEDIUM:
67
+ size = "Medium"
68
+ else:
69
+ size = "Small"
70
+
71
+ print(json.dumps({
72
+ "isPothole": is_pothole,
73
+ "confidence": round(max_conf, 3),
74
+ "size": size,
75
+ "count": len(boxes),
76
+ "largestAreaFraction": round(max_frac, 4),
77
+ "boxes": boxes,
78
+ "model": "peterhdd/pothole-detection-yolov8",
79
+ }))
80
+ except Exception as e: # noqa: BLE001
81
+ print(json.dumps({"error": str(e)}))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
ai/gen_closeout.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Generate a clean Pothole Repair CLOSEOUT / Job Completion Sheet (.docx).
3
+
4
+ The post-repair record the crew completes (often via Box Forms): what was done,
5
+ materials, hours, before/after photos, actual cost, inspection, sign-off.
6
+
7
+ Usage: python gen_closeout.py <spec.json> <output.docx>
8
+ Works for live docs AND as a Box Doc Gen template when values are {{tags}}.
9
+ """
10
+ import sys
11
+ import os
12
+ import json
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+ from docx import Document
16
+ from docx.shared import Pt, Inches, RGBColor
17
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
18
+ from docx.enum.table import WD_ALIGN_VERTICAL
19
+ from docx.oxml.ns import qn
20
+ from docx.oxml import OxmlElement
21
+ # reuse the work order's polished styling helpers for a consistent look
22
+ from gen_workorder import (
23
+ NAVY, GREY, LIGHT, DARK, WHITE,
24
+ shade, no_borders, grid, cell_pad, widths, run, spacer, section, info_grid, sign_block,
25
+ )
26
+
27
+ GREEN = RGBColor(0x15, 0x7A, 0x3F)
28
+
29
+
30
+ def photo_row(doc, before, after):
31
+ """Two-column before/after photo row (embeds files; otherwise shows the link/placeholder)."""
32
+ t = doc.add_table(rows=2, cols=2)
33
+ no_borders(t)
34
+ cell_pad(t, top=20, bottom=20, left=20, right=80)
35
+ run(t.cell(0, 0).paragraphs[0], "BEFORE (reported)", size=7.5, bold=True, color=LIGHT, caps=True)
36
+ run(t.cell(0, 1).paragraphs[0], "AFTER (repaired)", size=7.5, bold=True, color=LIGHT, caps=True)
37
+ for col, val in ((0, before), (1, after)):
38
+ cell = t.cell(1, col)
39
+ p = cell.paragraphs[0]
40
+ if val and os.path.exists(str(val)):
41
+ try:
42
+ run(p, "")
43
+ p.add_run().add_picture(val, width=Inches(3.0))
44
+ continue
45
+ except Exception:
46
+ pass
47
+ if val:
48
+ run(p, str(val), size=9, color=NAVY)
49
+ else:
50
+ run(p, "☐ photo attached", size=9.5, color=GREY)
51
+ widths(t, [3.45, 3.45])
52
+
53
+
54
+ def main():
55
+ if len(sys.argv) < 3:
56
+ print(json.dumps({"error": "usage: gen_closeout.py <spec.json> <output.docx>"}))
57
+ return
58
+ with open(sys.argv[1], "r", encoding="utf-8") as f:
59
+ s = json.load(f)
60
+ out_path = sys.argv[2]
61
+ g = lambda k, d="": s.get(k) if s.get(k) not in (None, "") else d # noqa: E731
62
+
63
+ doc = Document()
64
+ normal = doc.styles["Normal"]
65
+ normal.font.name = "Calibri"
66
+ normal.font.size = Pt(10)
67
+ normal.font.color.rgb = DARK
68
+ for sec in doc.sections:
69
+ sec.left_margin = sec.right_margin = Inches(0.75)
70
+ sec.top_margin = sec.bottom_margin = Inches(0.6)
71
+
72
+ # ── Header ───────────────────────────────────────────────────────────────
73
+ head = doc.add_table(rows=1, cols=2)
74
+ no_borders(head)
75
+ cell_pad(head, 0, 0, 0, 0)
76
+ L = head.cell(0, 0).paragraphs[0]
77
+ run(L, g("city", "City of Philadelphia"), size=9, bold=True, color=NAVY)
78
+ L.add_run("\n")
79
+ run(L, g("department", "Department of Streets Β· Road Maintenance Division"), size=8.5, color=GREY)
80
+ L.add_run("\n\n")
81
+ run(L, "Pothole Repair β€” Closeout Job Sheet", size=18, bold=True, color=DARK)
82
+ R = head.cell(0, 1)
83
+ rp = R.paragraphs[0]
84
+ rp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
85
+ run(rp, "CASE ID", size=7.5, bold=True, color=LIGHT, caps=True)
86
+ rp2 = R.add_paragraph(); rp2.alignment = WD_ALIGN_PARAGRAPH.RIGHT
87
+ run(rp2, g("caseId", "____________"), size=15, bold=True, color=NAVY)
88
+ rp4 = R.add_paragraph(); rp4.alignment = WD_ALIGN_PARAGRAPH.RIGHT
89
+ run(rp4, "Status ", size=9, color=GREY)
90
+ run(rp4, "COMPLETED", size=9, bold=True, color=GREEN)
91
+ widths(head, [4.0, 3.0])
92
+
93
+ rule = doc.add_paragraph(); rule.paragraph_format.space_before = Pt(4); rule.paragraph_format.space_after = Pt(2)
94
+ pbdr = OxmlElement("w:pBdr"); b = OxmlElement("w:bottom")
95
+ b.set(qn("w:val"), "single"); b.set(qn("w:sz"), "18"); b.set(qn("w:color"), "0B3D91")
96
+ pbdr.append(b); rule._p.get_or_add_pPr().append(pbdr)
97
+
98
+ # ── Repair details (ONLY the crew closeout form fields) ──────────────────
99
+ section(doc, "Repair Details")
100
+ info_grid(doc, [
101
+ ("Case ID", g("caseId")),
102
+ ("Repair Completed?", g("repairCompleted", "☐ Yes ☐ No")),
103
+ ("Repair Method", g("repairMethods", "____________________")),
104
+ ("Materials Used", g("materialsUsed", "____________________")),
105
+ ("Labor Hours", g("laborHours", "________")),
106
+ ])
107
+
108
+ # ── Crew (from the form) ─────────────────────────────────────────────────
109
+ section(doc, "Crew")
110
+ info_grid(doc, [
111
+ ("Crew Name", g("crewName", "____________________")),
112
+ ("Crew Contact Number", g("crewContact", "____________________")),
113
+ ], cols=2)
114
+
115
+ # ── Reported photo (the citizen's pre-repair photo, so the crew sees what to fix) ──
116
+ reported = s.get("reportedPhotoPath")
117
+ if reported and os.path.exists(str(reported)):
118
+ section(doc, "Reported Photo (Pre-Repair)")
119
+ try:
120
+ doc.add_picture(reported, width=Inches(4.2))
121
+ except Exception:
122
+ pass
123
+
124
+ # ── After photo (from the form) ──────────────────────────────────────────
125
+ section(doc, "After Photo")
126
+ after = s.get("afterPhotoPath") or g("afterPhoto")
127
+ if after and os.path.exists(str(after)):
128
+ try:
129
+ doc.add_picture(after, width=Inches(4.2))
130
+ except Exception:
131
+ run(doc.add_paragraph(), str(after), size=9.5, color=NAVY)
132
+ elif after:
133
+ run(doc.add_paragraph(), str(after), size=9.5, color=NAVY)
134
+ else:
135
+ run(doc.add_paragraph(), "☐ photo attached to closeout form", size=9.5, color=GREY)
136
+
137
+ # ── Sign-off ─────────────────────────────────────────────────────────────
138
+ section(doc, "Sign-Off")
139
+ sign_block(doc, ["Crew Foreman", "Supervisor"])
140
+
141
+ foot = doc.add_paragraph(); foot.paragraph_format.space_before = Pt(10)
142
+ run(foot, "Generated by PotholeIQ" + (" Β· " + g("completedDate") if g("completedDate") else ""), size=8, italic=True, color=LIGHT)
143
+
144
+ doc.save(out_path)
145
+ print(json.dumps({"ok": True, "output": out_path}))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
ai/gen_doc.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Generate a styled .docx (Review Sheet or Work Order) with the pothole photo embedded.
3
+
4
+ Box Doc Gen cannot reliably embed an image-from-URL, so we render the document here
5
+ with python-docx (the image embeds cleanly and Box previews .docx natively).
6
+
7
+ Usage: python gen_doc.py <spec.json> <output.docx>
8
+
9
+ spec.json:
10
+ {
11
+ "title": "...", "subtitle": "...",
12
+ "meta": [["Label","Value"], ...], # small lines under the title (optional)
13
+ "sections": [ {"heading":"...", "fields":[["Label","Value"], ...]}, ... ],
14
+ "imagePath": "/abs/path/photo.jpg" | null,
15
+ "imageCaption": "..." | null,
16
+ "notes": "..." | null,
17
+ "accent": "0061D5" # hex, optional (Box blue default)
18
+ }
19
+ """
20
+ import sys
21
+ import os
22
+ import json
23
+ from docx import Document
24
+ from docx.shared import Pt, Inches, RGBColor
25
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
26
+
27
+
28
+ def hex_rgb(h, default=(0x00, 0x61, 0xD5)):
29
+ try:
30
+ h = (h or "").lstrip("#")
31
+ return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
32
+ except Exception:
33
+ return RGBColor(*default)
34
+
35
+
36
+ GREY = RGBColor(0x5B, 0x64, 0x76)
37
+ DARK = RGBColor(0x17, 0x20, 0x33)
38
+
39
+
40
+ def add_title(doc, title, subtitle, accent):
41
+ p = doc.add_paragraph()
42
+ r = p.add_run(title or "")
43
+ r.bold = True
44
+ r.font.size = Pt(22)
45
+ r.font.color.rgb = accent
46
+ if subtitle:
47
+ p2 = doc.add_paragraph()
48
+ r2 = p2.add_run(subtitle)
49
+ r2.font.size = Pt(11)
50
+ r2.font.color.rgb = GREY
51
+
52
+
53
+ def add_meta(doc, meta):
54
+ if not meta:
55
+ return
56
+ for label, value in meta:
57
+ p = doc.add_paragraph()
58
+ p.paragraph_format.space_after = Pt(0)
59
+ rl = p.add_run(f"{label}: ")
60
+ rl.bold = True
61
+ rl.font.size = Pt(9)
62
+ rl.font.color.rgb = GREY
63
+ rv = p.add_run("" if value is None else str(value))
64
+ rv.font.size = Pt(9)
65
+ rv.font.color.rgb = DARK
66
+
67
+
68
+ def add_heading(doc, text, accent):
69
+ p = doc.add_paragraph()
70
+ p.paragraph_format.space_before = Pt(12)
71
+ r = p.add_run((text or "").upper())
72
+ r.bold = True
73
+ r.font.size = Pt(11)
74
+ r.font.color.rgb = accent
75
+
76
+
77
+ def add_images_grid(doc, images, accent):
78
+ """Lay out a list of {path, caption} images. Two images -> side-by-side
79
+ before/after; otherwise stacked. Each gets its caption underneath."""
80
+ valid = [im for im in images if im.get("path") and os.path.exists(im["path"])]
81
+ if not valid:
82
+ return
83
+ add_heading(doc, "Before / After Photos", accent)
84
+ if len(valid) == 2:
85
+ table = doc.add_table(rows=1, cols=2)
86
+ for cell, im in zip(table.rows[0].cells, valid):
87
+ p = cell.paragraphs[0]
88
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
89
+ try:
90
+ p.add_run().add_picture(im["path"], width=Inches(3.05))
91
+ except Exception as e: # noqa: BLE001
92
+ p.add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9)
93
+ cap = cell.add_paragraph()
94
+ cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
95
+ rc = cap.add_run(im.get("caption") or "")
96
+ rc.bold = True
97
+ rc.font.size = Pt(9)
98
+ rc.font.color.rgb = GREY
99
+ return
100
+ for im in valid:
101
+ try:
102
+ doc.add_picture(im["path"], width=Inches(4.6))
103
+ doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT
104
+ except Exception as e: # noqa: BLE001
105
+ doc.add_paragraph().add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9)
106
+ cap = im.get("caption")
107
+ if cap:
108
+ pc = doc.add_paragraph()
109
+ rc = pc.add_run(cap)
110
+ rc.italic = True
111
+ rc.font.size = Pt(9)
112
+ rc.font.color.rgb = GREY
113
+
114
+
115
+ def add_kv_table(doc, fields):
116
+ table = doc.add_table(rows=0, cols=2)
117
+ table.style = "Light List Accent 1"
118
+ for label, value in fields:
119
+ cells = table.add_row().cells
120
+ rl = cells[0].paragraphs[0].add_run(str(label))
121
+ rl.bold = True
122
+ rl.font.size = Pt(9)
123
+ rl.font.color.rgb = GREY
124
+ val = "β€”" if value in (None, "") else str(value)
125
+ rv = cells[1].paragraphs[0].add_run(val)
126
+ rv.font.size = Pt(11)
127
+ rv.font.color.rgb = DARK
128
+ for row in table.rows:
129
+ row.cells[0].width = Inches(2.3)
130
+ row.cells[1].width = Inches(4.0)
131
+
132
+
133
+ def main():
134
+ if len(sys.argv) < 3:
135
+ print(json.dumps({"error": "usage: gen_doc.py <spec.json> <output.docx>"}))
136
+ return
137
+ spec_path, out_path = sys.argv[1], sys.argv[2]
138
+ with open(spec_path, "r", encoding="utf-8") as f:
139
+ spec = json.load(f)
140
+
141
+ accent = hex_rgb(spec.get("accent"))
142
+ doc = Document()
143
+ for s in doc.sections:
144
+ s.left_margin = s.right_margin = Inches(0.8)
145
+ s.top_margin = s.bottom_margin = Inches(0.7)
146
+
147
+ add_title(doc, spec.get("title"), spec.get("subtitle"), accent)
148
+ add_meta(doc, spec.get("meta") or [])
149
+
150
+ for section in spec.get("sections") or []:
151
+ add_heading(doc, section.get("heading"), accent)
152
+ add_kv_table(doc, section.get("fields") or [])
153
+
154
+ # Multi-image (e.g. before/after) takes precedence over a single site photo.
155
+ images = spec.get("images")
156
+ if isinstance(images, list) and images:
157
+ add_images_grid(doc, images, accent)
158
+
159
+ image_path = spec.get("imagePath")
160
+ if not (isinstance(images, list) and images) and image_path and os.path.exists(image_path):
161
+ add_heading(doc, "Site Photo", accent)
162
+ try:
163
+ doc.add_picture(image_path, width=Inches(4.6))
164
+ doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.LEFT
165
+ cap = spec.get("imageCaption")
166
+ if cap:
167
+ pc = doc.add_paragraph()
168
+ rc = pc.add_run(cap)
169
+ rc.italic = True
170
+ rc.font.size = Pt(9)
171
+ rc.font.color.rgb = GREY
172
+ except Exception as e: # noqa: BLE001
173
+ pe = doc.add_paragraph()
174
+ pe.add_run(f"[photo could not be embedded: {e}]").font.size = Pt(9)
175
+
176
+ notes = spec.get("notes")
177
+ if notes:
178
+ add_heading(doc, "Notes", accent)
179
+ doc.add_paragraph(str(notes))
180
+
181
+ doc.save(out_path)
182
+ print(json.dumps({"ok": True, "output": out_path}))
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
ai/gen_workorder.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Generate a clean, professional municipal Pothole Repair WORK ORDER (.docx).
3
+
4
+ Modeled on standard public-works work order forms. Known data is filled in;
5
+ crew-completed sections are blank lines / checkboxes.
6
+
7
+ Usage: python gen_workorder.py <spec.json> <output.docx>
8
+ (Same spec keys as before β€” works for live docs AND as a Box Doc Gen template
9
+ when values are passed as {{tags}}.)
10
+ """
11
+ import sys
12
+ import os
13
+ import json
14
+ from docx import Document
15
+ from docx.shared import Pt, Inches, RGBColor
16
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
17
+ from docx.enum.table import WD_ALIGN_VERTICAL
18
+ from docx.oxml.ns import qn
19
+ from docx.oxml import OxmlElement
20
+
21
+ NAVY = RGBColor(0x0B, 0x3D, 0x91)
22
+ GREY = RGBColor(0x6B, 0x72, 0x80)
23
+ LIGHT = RGBColor(0x9A, 0xA1, 0xAD)
24
+ DARK = RGBColor(0x1A, 0x1F, 0x2B)
25
+ WHITE = RGBColor(0xFF, 0xFF, 0xFF)
26
+
27
+
28
+ # ── low-level helpers ────────────────────────────────────────────────────────
29
+ def shade(cell, hex_fill):
30
+ tcPr = cell._tc.get_or_add_tcPr()
31
+ shd = OxmlElement("w:shd")
32
+ shd.set(qn("w:val"), "clear")
33
+ shd.set(qn("w:color"), "auto")
34
+ shd.set(qn("w:fill"), hex_fill)
35
+ tcPr.append(shd)
36
+
37
+
38
+ def no_borders(table):
39
+ borders = OxmlElement("w:tblBorders")
40
+ for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
41
+ e = OxmlElement(f"w:{edge}")
42
+ e.set(qn("w:val"), "none")
43
+ borders.append(e)
44
+ table._tbl.tblPr.append(borders)
45
+
46
+
47
+ def hairlines(table, color="E3E7EE"):
48
+ """Subtle horizontal rules only β€” clean ledger look."""
49
+ borders = OxmlElement("w:tblBorders")
50
+ for edge in ("top", "left", "right", "insideV"):
51
+ e = OxmlElement(f"w:{edge}")
52
+ e.set(qn("w:val"), "none")
53
+ borders.append(e)
54
+ for edge in ("bottom", "insideH"):
55
+ e = OxmlElement(f"w:{edge}")
56
+ e.set(qn("w:val"), "single")
57
+ e.set(qn("w:sz"), "4")
58
+ e.set(qn("w:color"), color)
59
+ borders.append(e)
60
+ table._tbl.tblPr.append(borders)
61
+
62
+
63
+ def grid(table, color="D7DCE5"):
64
+ borders = OxmlElement("w:tblBorders")
65
+ for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
66
+ e = OxmlElement(f"w:{edge}")
67
+ e.set(qn("w:val"), "single")
68
+ e.set(qn("w:sz"), "4")
69
+ e.set(qn("w:color"), color)
70
+ borders.append(e)
71
+ table._tbl.tblPr.append(borders)
72
+
73
+
74
+ def cell_pad(table, top=60, bottom=60, left=100, right=100):
75
+ mar = OxmlElement("w:tblCellMar")
76
+ for side, val in (("top", top), ("bottom", bottom), ("left", left), ("right", right)):
77
+ e = OxmlElement(f"w:{side}")
78
+ e.set(qn("w:w"), str(val))
79
+ e.set(qn("w:type"), "dxa")
80
+ mar.append(e)
81
+ table._tbl.tblPr.append(mar)
82
+
83
+
84
+ def widths(table, ws):
85
+ for row in table.rows:
86
+ for i, w in enumerate(ws):
87
+ if i < len(row.cells):
88
+ row.cells[i].width = Inches(w)
89
+
90
+
91
+ def run(p, text, size=10, bold=False, color=DARK, italic=False, caps=False):
92
+ r = p.add_run(text)
93
+ r.bold = bold
94
+ r.italic = italic
95
+ r.font.size = Pt(size)
96
+ r.font.color.rgb = color
97
+ if caps:
98
+ rPr = r._element.get_or_add_rPr()
99
+ c = OxmlElement("w:caps")
100
+ c.set(qn("w:val"), "true")
101
+ rPr.append(c)
102
+ return r
103
+
104
+
105
+ def spacer(doc, pt=6):
106
+ p = doc.add_paragraph()
107
+ p.paragraph_format.space_after = Pt(0)
108
+ p.paragraph_format.space_before = Pt(0)
109
+ p.add_run("").font.size = Pt(pt)
110
+
111
+
112
+ # ── building blocks ──────────────────────────────────────────────────────────
113
+ def section(doc, text):
114
+ spacer(doc, 4)
115
+ t = doc.add_table(rows=1, cols=1)
116
+ no_borders(t)
117
+ cell_pad(t, top=50, bottom=50, left=120, right=120)
118
+ c = t.cell(0, 0)
119
+ shade(c, "0B3D91")
120
+ p = c.paragraphs[0]
121
+ run(p, text, size=9.5, bold=True, color=WHITE, caps=True)
122
+ spacer(doc, 2)
123
+
124
+
125
+ def info_grid(doc, pairs, cols=2):
126
+ rows = (len(pairs) + cols - 1) // cols
127
+ t = doc.add_table(rows=rows, cols=cols)
128
+ hairlines(t)
129
+ cell_pad(t, top=70, bottom=70, left=40, right=160)
130
+ for i, (label, value) in enumerate(pairs):
131
+ cell = t.cell(i // cols, i % cols)
132
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
133
+ p = cell.paragraphs[0]
134
+ p.paragraph_format.space_after = Pt(1)
135
+ run(p, label, size=7.5, bold=True, color=LIGHT, caps=True)
136
+ p2 = cell.add_paragraph()
137
+ p2.paragraph_format.space_before = Pt(0)
138
+ run(p2, "β€”" if value in (None, "") else str(value), size=10.5, color=DARK)
139
+ widths(t, [3.45] * cols)
140
+ return t
141
+
142
+
143
+ def checklist(doc, title, items, per_row=3):
144
+ p = doc.add_paragraph()
145
+ p.paragraph_format.space_after = Pt(3)
146
+ run(p, title, size=8.5, bold=True, color=GREY, caps=True)
147
+ rows = (len(items) + per_row - 1) // per_row
148
+ t = doc.add_table(rows=rows, cols=per_row)
149
+ no_borders(t)
150
+ cell_pad(t, top=30, bottom=30, left=20, right=40)
151
+ for i, item in enumerate(items):
152
+ run(t.cell(i // per_row, i % per_row).paragraphs[0], "☐ " + item, size=9.5, color=DARK)
153
+
154
+
155
+ def sign_block(doc, roles):
156
+ t = doc.add_table(rows=len(roles), cols=3)
157
+ no_borders(t)
158
+ cell_pad(t, top=130, bottom=40, left=0, right=80)
159
+ for i, role in enumerate(roles):
160
+ run(t.cell(i, 0).paragraphs[0], role, size=9, bold=True, color=GREY, caps=True)
161
+ run(t.cell(i, 1).paragraphs[0], "β€”" * 26, size=10, color=RGBColor(0xC2, 0xC8, 0xD2))
162
+ run(t.cell(i, 2).paragraphs[0], "Date: " + "β€”" * 10, size=9, color=GREY)
163
+ widths(t, [1.7, 3.2, 1.4])
164
+
165
+
166
+ # ── main ─────────────────────────────────────────────────────────────────────
167
+ def main():
168
+ if len(sys.argv) < 3:
169
+ print(json.dumps({"error": "usage: gen_workorder.py <spec.json> <output.docx>"}))
170
+ return
171
+ with open(sys.argv[1], "r", encoding="utf-8") as f:
172
+ s = json.load(f)
173
+ out_path = sys.argv[2]
174
+ g = lambda k, d="": s.get(k) if s.get(k) not in (None, "") else d # noqa: E731
175
+
176
+ doc = Document()
177
+ normal = doc.styles["Normal"]
178
+ normal.font.name = "Calibri"
179
+ normal.font.size = Pt(10)
180
+ normal.font.color.rgb = DARK
181
+ for sec in doc.sections:
182
+ sec.left_margin = sec.right_margin = Inches(0.75)
183
+ sec.top_margin = sec.bottom_margin = Inches(0.6)
184
+
185
+ # ── Header band ──────────────────────────────────────────────────────────
186
+ head = doc.add_table(rows=1, cols=2)
187
+ no_borders(head)
188
+ cell_pad(head, top=0, bottom=0, left=0, right=0)
189
+ L = head.cell(0, 0).paragraphs[0]
190
+ run(L, g("city", "City of Philadelphia"), size=9, bold=True, color=NAVY)
191
+ L.add_run("\n")
192
+ run(L, g("department", "Department of Streets Β· Road Maintenance Division"), size=8.5, color=GREY)
193
+ L.add_run("\n\n")
194
+ run(L, "Pothole Repair Work Order", size=19, bold=True, color=DARK)
195
+
196
+ R = head.cell(0, 1)
197
+ R.vertical_alignment = WD_ALIGN_VERTICAL.TOP
198
+ rp = R.paragraphs[0]
199
+ rp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
200
+ run(rp, "WORK ORDER NO.", size=7.5, bold=True, color=LIGHT, caps=True)
201
+ rp2 = R.add_paragraph()
202
+ rp2.alignment = WD_ALIGN_PARAGRAPH.RIGHT
203
+ run(rp2, g("workOrderId", "WO-________"), size=15, bold=True, color=NAVY)
204
+ rp3 = R.add_paragraph()
205
+ rp3.alignment = WD_ALIGN_PARAGRAPH.RIGHT
206
+ run(rp3, "Issued ", size=9, color=GREY)
207
+ run(rp3, g("issuedDate", "____________"), size=9, bold=True, color=DARK)
208
+ rp4 = R.add_paragraph()
209
+ rp4.alignment = WD_ALIGN_PARAGRAPH.RIGHT
210
+ run(rp4, "Priority ", size=9, color=GREY)
211
+ run(rp4, g("priority", "________"), size=9, bold=True, color=RGBColor(0xC2, 0x41, 0x0C))
212
+ run(rp4, " Status ", size=9, color=GREY)
213
+ run(rp4, g("status", "OPEN"), size=9, bold=True, color=DARK)
214
+ widths(head, [4.0, 3.0])
215
+
216
+ # navy rule
217
+ rule = doc.add_paragraph()
218
+ rule.paragraph_format.space_before = Pt(4)
219
+ rule.paragraph_format.space_after = Pt(2)
220
+ pbdr = OxmlElement("w:pBdr")
221
+ b = OxmlElement("w:bottom")
222
+ b.set(qn("w:val"), "single")
223
+ b.set(qn("w:sz"), "18")
224
+ b.set(qn("w:color"), "0B3D91")
225
+ pbdr.append(b)
226
+ rule._p.get_or_add_pPr().append(pbdr)
227
+
228
+ # ── Sections ─────────────────────────────────────────────────────────────
229
+ section(doc, "Request Information")
230
+ info_grid(doc, [
231
+ ("Case / Request ID", g("caseId")),
232
+ ("Date Reported", g("reportedDate")),
233
+ ("Reported By", g("reportedBy", "Citizen")),
234
+ ("Source", g("source", "Citizen Portal (PotholeIQ)")),
235
+ ("AI Review Status", g("aiReviewStatus")),
236
+ ("Duplicate Status", g("duplicateStatus")),
237
+ ])
238
+
239
+ section(doc, "Location")
240
+ info_grid(doc, [
241
+ ("Address", g("address")),
242
+ ("GPS Coordinates", g("coords")),
243
+ ("Ward", g("ward")),
244
+ ("District", g("district")),
245
+ ("Maintenance Zone", g("zone")),
246
+ ("Cross Streets", g("crossStreets", "β€”")),
247
+ ])
248
+
249
+ section(doc, "Defect Description")
250
+ info_grid(doc, [
251
+ ("Defect Type", g("defectType", "Pothole")),
252
+ ("Pothole Size (AI)", g("size")),
253
+ ("Severity Score", g("severityScore")),
254
+ ("Severity Level", g("severityLevel")),
255
+ ("Road Surface", g("roadSurface", "Asphalt")),
256
+ ("Weather Risk", g("weatherRisk")),
257
+ ])
258
+
259
+ if s.get("summary"):
260
+ section(doc, "AI Case Summary")
261
+ run(doc.add_paragraph(), s.get("summary"), size=10, color=DARK)
262
+
263
+ img = s.get("imagePath")
264
+ if img and os.path.exists(img):
265
+ section(doc, "Site Photo (Pre-Repair)")
266
+ try:
267
+ doc.add_picture(img, width=Inches(4.4))
268
+ run(doc.add_paragraph(), "Citizen-submitted, AI-classified photo. Verify extent on arrival.", size=8.5, italic=True, color=GREY)
269
+ except Exception as e: # noqa: BLE001
270
+ run(doc.add_paragraph(), f"[photo not embedded: {e}]", size=9, color=GREY)
271
+ elif s.get("photoUrl"):
272
+ section(doc, "Site Photo (Pre-Repair)")
273
+ p = doc.add_paragraph()
274
+ run(p, "View photo: ", size=9.5, bold=True, color=GREY)
275
+ run(p, s.get("photoUrl"), size=9.5, color=NAVY)
276
+
277
+ section(doc, "Assignment & Scheduling")
278
+ info_grid(doc, [
279
+ ("Assigned Crew / Foreman", g("assignedCrew", "____________________")),
280
+ ("Crew ID", g("crewId", "____________")),
281
+ ("Scheduled Date", g("scheduledDate", "____________")),
282
+ ("Target Completion (SLA)", g("targetDate", "____________")),
283
+ ("Estimated Labor Hours", g("estHours", "________")),
284
+ ("Authorized By", g("authorizedBy", "____________________")),
285
+ ])
286
+
287
+ section(doc, "Materials & Equipment")
288
+ checklist(doc, "Materials (check + enter quantity)", [
289
+ "Cold Mix Asphalt ____ t", "Hot Mix Asphalt ____ t", "Tack Coat",
290
+ "Crack Sealant", "Aggregate / Gravel ____", "Other: __________",
291
+ ])
292
+ spacer(doc, 3)
293
+ checklist(doc, "Equipment", [
294
+ "Asphalt Roller", "Plate Compactor", "Dump Truck",
295
+ "Air Compressor", "Saw Cutter", "Hand Tools",
296
+ ])
297
+
298
+ section(doc, "Labor & Cost")
299
+ cost = doc.add_table(rows=5, cols=5)
300
+ grid(cost)
301
+ cell_pad(cost, top=50, bottom=50, left=90, right=90)
302
+ for j, h in enumerate(["Crew Member", "Role", "Hours", "Rate ($)", "Cost ($)"]):
303
+ c = cost.cell(0, j)
304
+ shade(c, "EEF2F9")
305
+ run(c.paragraphs[0], h, size=8.5, bold=True, color=NAVY)
306
+ widths(cost, [2.4, 1.4, 0.9, 1.0, 1.2])
307
+ tot = doc.add_paragraph()
308
+ tot.alignment = WD_ALIGN_PARAGRAPH.RIGHT
309
+ tot.paragraph_format.space_before = Pt(4)
310
+ run(tot, "Labor $ ______ Materials $ ______ Equipment $ ______ ", size=9.5, color=GREY)
311
+ run(tot, "TOTAL $ __________", size=10, bold=True, color=DARK)
312
+
313
+ section(doc, "Completion & Sign-Off")
314
+ run(doc.add_paragraph(), "Work Performed", size=8.5, bold=True, color=GREY, caps=True)
315
+ for _ in range(2):
316
+ p = doc.add_paragraph()
317
+ run(p, "β€”" * 78, size=10, color=RGBColor(0xD7, 0xDC, 0xE5))
318
+ spacer(doc, 2)
319
+ info_grid(doc, [
320
+ ("Date Started", "____________"),
321
+ ("Date Completed", "____________"),
322
+ ("Actual Labor Hours", "________"),
323
+ ("After Photo Attached", "☐ Yes ☐ No"),
324
+ ])
325
+ spacer(doc, 4)
326
+ sign_block(doc, ["Crew Foreman", "Supervisor Approval", "Inspector Sign-Off"])
327
+
328
+ section(doc, "Safety / Traffic Control")
329
+ checklist(doc, "Measures used", [
330
+ "Cones / Barricades", "Flagger", "Lane Closure",
331
+ "Advance Warning Signage", "Arrow Board", "None Required",
332
+ ])
333
+
334
+ foot = doc.add_paragraph()
335
+ foot.paragraph_format.space_before = Pt(10)
336
+ run(foot, "Generated by PotholeIQ" + (" Β· " + g("issuedDate") if g("issuedDate") else ""), size=8, italic=True, color=LIGHT)
337
+
338
+ doc.save(out_path)
339
+ print(json.dumps({"ok": True, "output": out_path}))
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()
ai/pothole-yolov8.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:af2ac6ce7bfec72e71643659ac946caf80ced84869e526a60135c457abfbb200
3
+ size 22524266
ai/pothole_server.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Persistent YOLOv8 pothole classifier worker (real-time).
3
+
4
+ Loads the model ONCE at startup, then serves one request per line from stdin
5
+ and writes one JSON result per line to stdout. This avoids the multi-second
6
+ cold start of loading torch + the model on every request.
7
+
8
+ Protocol:
9
+ startup -> emits {"ready": true} (or {"error": "..."} and exits)
10
+ stdin -> one image file path per line ("__quit__" to stop)
11
+ stdout -> one JSON result per line:
12
+ {"isPothole":bool,"confidence":float,"size":"Small|Medium|Large"|null,
13
+ "count":int,"largestAreaFraction":float,"boxes":[...],"model":"..."}
14
+ or {"error":"..."} for that request.
15
+ """
16
+ import sys
17
+ import os
18
+ import json
19
+
20
+ # Size = area (as a fraction of the whole frame) of the largest *confident* pothole
21
+ # detection. A single 2D photo has no true scale, so box area is a heuristic proxy.
22
+ # >= SIZE_LARGE -> Large
23
+ # >= SIZE_MEDIUM -> Medium
24
+ # else -> Small
25
+ # Thresholds are calibrated to the ground-truth pothole box-area distribution
26
+ # (3,625 annotated boxes): SIZE_MEDIUM ~= 40th percentile, SIZE_LARGE ~= 82nd
27
+ # percentile. That yields a sensible operational split of roughly 40% Small /
28
+ # 42% Medium / 18% Large instead of over-calling everything "Small".
29
+ SIZE_LARGE = 0.09
30
+ SIZE_MEDIUM = 0.013
31
+ # CONF_THRESHOLD: minimum confidence for a box to count as a detection at all.
32
+ CONF_THRESHOLD = 0.25
33
+ # SIZE_CONF_FLOOR: a box must clear this higher bar before its area is allowed to
34
+ # drive the size label. This stops low-confidence, sprawling false boxes (e.g. a
35
+ # shadow spanning the frame) from inflating a real, smaller pothole to "Large".
36
+ SIZE_CONF_FLOOR = 0.45
37
+ MODEL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pothole-yolov8.pt")
38
+
39
+
40
+ def emit(obj):
41
+ sys.stdout.write(json.dumps(obj) + "\n")
42
+ sys.stdout.flush()
43
+
44
+
45
+ def classify(model, image_path):
46
+ if not os.path.exists(image_path):
47
+ return {"error": "image not found: " + image_path}
48
+ try:
49
+ result = model.predict(image_path, conf=CONF_THRESHOLD, verbose=False)[0]
50
+ h, w = result.orig_shape
51
+ frame_area = float(h * w) if h and w else 1.0
52
+
53
+ boxes = []
54
+ max_frac = 0.0 # largest box area (any confidence) β€” reported for transparency
55
+ max_conf = 0.0 # best detection confidence
56
+ size_frac = 0.0 # area that actually drives the size label (confident boxes only)
57
+ best_conf_frac = 0.0 # area of the single highest-confidence box (fallback)
58
+ best_conf = -1.0
59
+ for b in result.boxes:
60
+ x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist())
61
+ conf = float(b.conf[0])
62
+ frac = ((x2 - x1) * (y2 - y1)) / frame_area
63
+ boxes.append({
64
+ "x1": round(x1), "y1": round(y1), "x2": round(x2), "y2": round(y2),
65
+ "conf": round(conf, 3), "areaFraction": round(frac, 4),
66
+ })
67
+ max_frac = max(max_frac, frac)
68
+ max_conf = max(max_conf, conf)
69
+ if conf >= SIZE_CONF_FLOOR:
70
+ size_frac = max(size_frac, frac)
71
+ if conf > best_conf:
72
+ best_conf = conf
73
+ best_conf_frac = frac
74
+
75
+ is_pothole = len(boxes) > 0
76
+ # Prefer the largest confident pothole; if none clear the floor, fall back to
77
+ # the area of the single most-confident detection (never a noisy wide box).
78
+ sizing_frac = size_frac if size_frac > 0 else best_conf_frac
79
+ if not is_pothole:
80
+ size = None
81
+ elif sizing_frac >= SIZE_LARGE:
82
+ size = "Large"
83
+ elif sizing_frac >= SIZE_MEDIUM:
84
+ size = "Medium"
85
+ else:
86
+ size = "Small"
87
+
88
+ return {
89
+ "isPothole": is_pothole,
90
+ "confidence": round(max_conf, 3),
91
+ "size": size,
92
+ "count": len(boxes),
93
+ "largestAreaFraction": round(max_frac, 4),
94
+ "sizeAreaFraction": round(sizing_frac, 4),
95
+ "boxes": boxes,
96
+ "model": "peterhdd/pothole-detection-yolov8",
97
+ }
98
+ except Exception as e: # noqa: BLE001
99
+ return {"error": str(e)}
100
+
101
+
102
+ def main():
103
+ if not os.path.exists(MODEL_FILE):
104
+ emit({"error": "model file missing: " + MODEL_FILE})
105
+ return
106
+ try:
107
+ from ultralytics import YOLO
108
+ except Exception as e: # noqa: BLE001
109
+ emit({"error": "ultralytics not installed: " + str(e)})
110
+ return
111
+ try:
112
+ model = YOLO(MODEL_FILE)
113
+ except Exception as e: # noqa: BLE001
114
+ emit({"error": "model load failed: " + str(e)})
115
+ return
116
+
117
+ emit({"ready": True})
118
+
119
+ for line in sys.stdin:
120
+ path = line.strip()
121
+ if not path:
122
+ continue
123
+ if path == "__quit__":
124
+ break
125
+ emit(classify(model, path))
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
authority-routing.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "default": {
3
+ "authorityName": "Department of Streets β€” Road Maintenance Division",
4
+ "authorityEmails": [
5
+ "road.maintenance@city.example.gov"
6
+ ],
7
+ "dispatchEmails": [
8
+ "field.dispatch@city.example.gov"
9
+ ]
10
+ },
11
+ "districts": {},
12
+ "zones": {}
13
+ }
backend-api.js ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ (function initPotholeBackend(globalScope) {
4
+ const API_PREFIX = '/api';
5
+
6
+ function isPlainObject(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+
10
+ function deepMerge(baseValue, incomingValue) {
11
+ if (!isPlainObject(baseValue) || !isPlainObject(incomingValue)) {
12
+ return incomingValue === undefined ? baseValue : incomingValue;
13
+ }
14
+
15
+ const result = { ...baseValue };
16
+ for (const [key, incoming] of Object.entries(incomingValue)) {
17
+ const existing = result[key];
18
+ if (Array.isArray(incoming)) {
19
+ result[key] = incoming.slice();
20
+ continue;
21
+ }
22
+ if (isPlainObject(existing) && isPlainObject(incoming)) {
23
+ result[key] = deepMerge(existing, incoming);
24
+ continue;
25
+ }
26
+ if (incoming !== undefined) {
27
+ result[key] = incoming;
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+
33
+ function stripPhotoDataUrl(value) {
34
+ if (Array.isArray(value)) {
35
+ return value.map((item) => stripPhotoDataUrl(item));
36
+ }
37
+
38
+ if (!isPlainObject(value)) {
39
+ return value;
40
+ }
41
+
42
+ const clone = {};
43
+ for (const [key, child] of Object.entries(value)) {
44
+ if (key === 'photoDataUrl') continue;
45
+ clone[key] = stripPhotoDataUrl(child);
46
+ }
47
+ return clone;
48
+ }
49
+
50
+ function toTimestamp(value) {
51
+ const parsed = Date.parse(String(value || ''));
52
+ return Number.isFinite(parsed) ? parsed : 0;
53
+ }
54
+
55
+ function caseFreshness(record) {
56
+ if (!record || typeof record !== 'object') return 0;
57
+ const report = record.report || {};
58
+ return Math.max(
59
+ toTimestamp(record.updatedAt),
60
+ toTimestamp(record.statusUpdatedAt),
61
+ toTimestamp(record.ts),
62
+ toTimestamp(record.timestamp),
63
+ toTimestamp(report.workflowLastUpdatedAt),
64
+ toTimestamp(report.resolvedAt),
65
+ toTimestamp(report.supervisor?.at),
66
+ toTimestamp(report.enrichment?.enrichedAt),
67
+ toTimestamp(report.submittedAt)
68
+ );
69
+ }
70
+
71
+ function mergeCaseLists(localCases, remoteCases) {
72
+ const merged = new Map();
73
+ const allCases = [...(Array.isArray(localCases) ? localCases : []), ...(Array.isArray(remoteCases) ? remoteCases : [])];
74
+
75
+ allCases.forEach((record) => {
76
+ if (!record || !record.caseId) return;
77
+ const existing = merged.get(record.caseId);
78
+ if (!existing) {
79
+ merged.set(record.caseId, record);
80
+ return;
81
+ }
82
+
83
+ const existingFreshness = caseFreshness(existing);
84
+ const nextFreshness = caseFreshness(record);
85
+ const nextPreferred = nextFreshness >= existingFreshness;
86
+ merged.set(
87
+ record.caseId,
88
+ nextPreferred ? deepMerge(existing, record) : deepMerge(record, existing)
89
+ );
90
+ });
91
+
92
+ return Array.from(merged.values()).sort((a, b) => {
93
+ const aTs = caseFreshness(a);
94
+ const bTs = caseFreshness(b);
95
+ if (aTs !== bTs) return aTs - bTs;
96
+ return String(a.caseId || '').localeCompare(String(b.caseId || ''));
97
+ });
98
+ }
99
+
100
+ async function request(path, options = {}) {
101
+ const response = await fetch(`${API_PREFIX}${path}`, {
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ ...(options.headers || {}),
105
+ },
106
+ ...options,
107
+ });
108
+
109
+ let payload = null;
110
+ try {
111
+ payload = await response.json();
112
+ } catch {
113
+ payload = null;
114
+ }
115
+
116
+ if (!response.ok) {
117
+ const errorMessage = payload?.error || `Request failed with HTTP ${response.status}`;
118
+ throw new Error(errorMessage);
119
+ }
120
+
121
+ return payload;
122
+ }
123
+
124
+ async function health() {
125
+ const payload = await request('/health', { method: 'GET' });
126
+ return payload?.ok === true;
127
+ }
128
+
129
+ async function getCases() {
130
+ const payload = await request('/cases', { method: 'GET' });
131
+ return Array.isArray(payload?.cases) ? payload.cases : [];
132
+ }
133
+
134
+ async function getCase(caseId) {
135
+ if (!caseId) return null;
136
+ const payload = await request(`/cases/${encodeURIComponent(caseId)}`, { method: 'GET' });
137
+ return payload?.case || null;
138
+ }
139
+
140
+ async function upsertCase(record) {
141
+ const payload = await request('/cases/upsert', {
142
+ method: 'POST',
143
+ body: JSON.stringify(stripPhotoDataUrl(record || {})),
144
+ });
145
+ return payload?.case || null;
146
+ }
147
+
148
+ async function bulkUpsertCases(cases) {
149
+ const payload = await request('/cases/bulk-upsert', {
150
+ method: 'POST',
151
+ body: JSON.stringify({
152
+ cases: Array.isArray(cases) ? cases.map((record) => stripPhotoDataUrl(record || {})) : [],
153
+ }),
154
+ });
155
+ return Array.isArray(payload?.cases) ? payload.cases : [];
156
+ }
157
+
158
+ async function patchCase(caseId, patch) {
159
+ if (!caseId) throw new Error('caseId is required.');
160
+ const payload = await request(`/cases/${encodeURIComponent(caseId)}/patch`, {
161
+ method: 'POST',
162
+ body: JSON.stringify(stripPhotoDataUrl(patch || {})),
163
+ });
164
+ return payload?.case || null;
165
+ }
166
+
167
+ async function submitSupervisorDecision(caseId, decisionPayload = {}) {
168
+ if (!caseId) throw new Error('caseId is required.');
169
+ const payload = await request('/workflow/supervisor-decision', {
170
+ method: 'POST',
171
+ body: JSON.stringify(stripPhotoDataUrl({
172
+ caseId,
173
+ ...decisionPayload,
174
+ })),
175
+ });
176
+ return payload || null;
177
+ }
178
+
179
+ async function submitCrewCloseout(caseId, closeoutPayload = {}) {
180
+ if (!caseId) throw new Error('caseId is required.');
181
+ const payload = await request('/workflow/crew-closeout', {
182
+ method: 'POST',
183
+ body: JSON.stringify(stripPhotoDataUrl({
184
+ caseId,
185
+ ...closeoutPayload,
186
+ })),
187
+ });
188
+ return payload || null;
189
+ }
190
+
191
+ async function submitDispatchPlan(dispatchPayload = {}) {
192
+ const payload = await request('/workflow/dispatch-plan', {
193
+ method: 'POST',
194
+ body: JSON.stringify(stripPhotoDataUrl(dispatchPayload || {})),
195
+ });
196
+ return payload || null;
197
+ }
198
+
199
+ async function resolveCase(caseId, resolvePayload = {}) {
200
+ if (!caseId) throw new Error('caseId is required.');
201
+ const payload = await request('/workflow/resolve-case', {
202
+ method: 'POST',
203
+ body: JSON.stringify(stripPhotoDataUrl({
204
+ caseId,
205
+ ...resolvePayload,
206
+ })),
207
+ });
208
+ return payload || null;
209
+ }
210
+
211
+ async function uploadCaseFile(caseId, fileName, fileBlob, contentType = '') {
212
+ if (!caseId) throw new Error('caseId is required.');
213
+ if (!fileName) throw new Error('fileName is required.');
214
+ if (!(fileBlob instanceof Blob)) {
215
+ throw new Error('fileBlob must be a Blob or File.');
216
+ }
217
+
218
+ const response = await fetch(
219
+ `${API_PREFIX}/box/upload-case-file?caseId=${encodeURIComponent(caseId)}&fileName=${encodeURIComponent(fileName)}`,
220
+ {
221
+ method: 'POST',
222
+ headers: contentType ? { 'Content-Type': contentType } : {},
223
+ body: fileBlob,
224
+ }
225
+ );
226
+
227
+ let payload = null;
228
+ try {
229
+ payload = await response.json();
230
+ } catch {
231
+ payload = null;
232
+ }
233
+
234
+ if (!response.ok) {
235
+ throw new Error(payload?.error || `Request failed with HTTP ${response.status}`);
236
+ }
237
+
238
+ return payload?.upload || null;
239
+ }
240
+
241
+ async function hydrateCasesIntoLocalStorage(storageKey = 'submitted_cases') {
242
+ if (typeof localStorage === 'undefined') return null;
243
+
244
+ let localCases = [];
245
+ try {
246
+ localCases = JSON.parse(localStorage.getItem(storageKey) || '[]');
247
+ } catch {
248
+ localCases = [];
249
+ }
250
+
251
+ const remoteCases = await getCases();
252
+ const mergedCases = mergeCaseLists(localCases, remoteCases);
253
+ localStorage.setItem(storageKey, JSON.stringify(mergedCases));
254
+
255
+ const remoteIds = new Set(remoteCases.map((item) => item?.caseId).filter(Boolean));
256
+ const unsyncedLocal = localCases.filter((item) => item?.caseId && !remoteIds.has(item.caseId));
257
+ if (unsyncedLocal.length) {
258
+ bulkUpsertCases(unsyncedLocal).catch((err) => {
259
+ console.warn('[PotholeBackend] could not sync local-only cases:', err);
260
+ });
261
+ }
262
+
263
+ return mergedCases;
264
+ }
265
+
266
+ async function syncLocalStorageCases(storageKey = 'submitted_cases') {
267
+ if (typeof localStorage === 'undefined') return [];
268
+ try {
269
+ const cases = JSON.parse(localStorage.getItem(storageKey) || '[]');
270
+ return bulkUpsertCases(cases);
271
+ } catch (err) {
272
+ console.warn('[PotholeBackend] could not read local cases for sync:', err);
273
+ return [];
274
+ }
275
+ }
276
+
277
+ globalScope.PotholeBackend = {
278
+ health,
279
+ getCases,
280
+ getCase,
281
+ upsertCase,
282
+ bulkUpsertCases,
283
+ patchCase,
284
+ submitSupervisorDecision,
285
+ submitDispatchPlan,
286
+ submitCrewCloseout,
287
+ resolveCase,
288
+ uploadCaseFile,
289
+ hydrateCasesIntoLocalStorage,
290
+ syncLocalStorageCases,
291
+ mergeCaseLists,
292
+ };
293
+ })(window);
box-build/BOX_APP_CONSOLE.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Box App β€” Pothole Operations Console Build Guide
2
+
3
+ This builds the operational dashboard + review console as a **Box App** (Box's no-code app
4
+ builder over your content + metadata), replacing the custom [ops-review.html](../ops-review.html).
5
+
6
+ > **Accuracy note:** the Box Apps no-code builder's exact control names evolve. This guide is
7
+ > correct at the structure/concept level (data source β†’ views β†’ fields β†’ actions β†’ roles).
8
+ > Confirm the equivalent control as you build, and tell me what you see if a step doesn't match β€”
9
+ > I'll adjust.
10
+
11
+ > **Hard dependency:** the app reads/writes the `potholeCase` metadata template
12
+ > ([metadata-template.json](metadata-template.json)). Build that template first, and have at
13
+ > least a few enriched cases in `Pothole Operations/Cases/` so the views show real data.
14
+
15
+ ---
16
+
17
+ ## App: "Pothole Operations Console"
18
+
19
+ **Data source:** the `potholeCase` metadata template, scoped to the
20
+ `Pothole Operations/Cases/` folder tree. Each case folder = one record; the case photo +
21
+ generated PDFs are the folder's files.
22
+
23
+ ---
24
+
25
+ ### View 1 β€” Operations Queue (default landing)
26
+
27
+ A filterable table of all cases.
28
+
29
+ - **Columns:** `caseId`, `status`, `severityLevel`, `ward`, `address`, `duplicateStatus`,
30
+ `submittedAt`, `assignedCrew`.
31
+ - **Default sort:** `severityScore` descending (Critical at top).
32
+ - **Saved filters / tabs:**
33
+ - *Needs Review* β†’ `status = Under Review`
34
+ - *Critical* β†’ `severityLevel = Critical`
35
+ - *Assigned* β†’ `status = Assigned`
36
+ - *Resolved* β†’ `status = Resolved`
37
+ - **Color rules:** Critical = red, High = orange, Medium = amber, Low = grey
38
+ (match the colors already in your data's `enrichment.severityColor`).
39
+
40
+ ### View 2 β€” Case Detail
41
+
42
+ Opens when a row is clicked.
43
+
44
+ - **Header:** `caseId` + `status` badge.
45
+ - **Photo:** preview the case photo from the folder.
46
+ - **AI summary panel:** the Box AI-generated case summary (set up in AI Studio) +
47
+ `aiReviewStatus`, `potholeSize`.
48
+ - **Severity breakdown:** `severityScore`, `severityLevel`, `weatherRisk`
49
+ (the prior/traffic/damage/weather detail lives in the backend `enrichment.severityBreakdown`
50
+ β€” surface it via the AI summary or an embedded note).
51
+ - **Location:** `address`, `ward`, `district`, `maintenanceZone`, `latitude`/`longitude`
52
+ (link out to a map).
53
+ - **Duplicate panel:** `duplicateStatus`, `duplicateCount`, `linkedCaseIds`.
54
+ - **Generated docs:** list the work order PDF + audit report from the folder.
55
+
56
+ ### View 3 β€” Dashboard (management)
57
+
58
+ Charts over the same data source:
59
+
60
+ - Cases by `status` (funnel: Submitted β†’ Under Review β†’ Assigned β†’ In Progress β†’ Resolved).
61
+ - Cases by `ward` (bar).
62
+ - `severityLevel` distribution (donut).
63
+ - Open cases older than N days (SLA watch).
64
+
65
+ ### View 4 (optional) β€” Citizen Status (read-only, externally shareable)
66
+
67
+ - Single-record lookup by `caseId`.
68
+ - Show **only**: `status`, `submittedAt`, `ward`, `resolvedAt`. Hide reporter contact,
69
+ internal notes, severity internals.
70
+
71
+ ---
72
+
73
+ ## Actions (buttons that drive the workflow)
74
+
75
+ Wire these on the Case Detail view. Each either writes metadata (which a Relay flow reacts to)
76
+ or calls your backend:
77
+
78
+ | Button | What it does |
79
+ |-------------------|------------------------------------------------------------------------------|
80
+ | **Approve** | Set `status = Assigned`, `supervisor = {current user}` β†’ Flow 2/3 fire. |
81
+ | **Reject** | Set `status = Rejected` (prompt for reason). |
82
+ | **Adjust severity** | Edit `severityLevel` before approving. |
83
+ | **Generate work order** | Trigger Box Doc Gen (or it auto-fires from the status change in Flow 3).|
84
+ | **Open in city system** | (Optional) HTTPS call to the external work-order system. |
85
+
86
+ > If the Box App builder in your tenant can't call outgoing webhooks directly, do it the
87
+ > metadata-driven way: the button just sets a metadata field, and **Box Automate (Relay)**
88
+ > watches that field and performs the action. This is the more robust pattern anyway.
89
+
90
+ ---
91
+
92
+ ## Roles & permissions
93
+
94
+ | Role | Sees / can do |
95
+ |------------------|----------------------------------------------------------------------|
96
+ | Ops reviewer | All cases; Approve/Reject/Adjust. |
97
+ | Ward engineer | Cases for their `ward`; Approve/Reject (Relay assigns the approval). |
98
+ | Crew | Assigned cases only; closeout via the **Box Form**, not this app. |
99
+ | Management | Dashboard view; read-only. |
100
+ | Citizen (public) | View 4 only, via a shared link. |
101
+
102
+ ---
103
+
104
+ ## Build order
105
+
106
+ 1. Confirm `potholeCase` template exists and a few enriched cases are present.
107
+ 2. Create the app β†’ point its data source at `Cases/` + the template.
108
+ 3. Build View 1 (Queue) β†’ verify real cases appear.
109
+ 4. Add View 2 (Detail) + the Approve/Reject buttons β†’ test one approval end-to-end with
110
+ [BOX_AUTOMATE_WORKFLOW.md](BOX_AUTOMATE_WORKFLOW.md) Flow 2 watching the field.
111
+ 5. Add View 3 (Dashboard) and View 4 (Citizen) last.
112
+
113
+ ---
114
+
115
+ ## How this maps to the old custom app
116
+
117
+ | Old custom page | New Box-native home |
118
+ |-------------------------------------|----------------------------------|
119
+ | [ops-review.html](../ops-review.html) | Box App Views 1–3 + actions |
120
+ | [deploy-crew.html](../deploy-crew.html) | Box App action + Relay Flow 3 |
121
+ | [crew-closeout.html](../crew-closeout.html) | Box Form + Box Sign (Flow 4) |
122
+ | [track.html](../track.html) | Box App View 4 (citizen) |
123
+ | [index.html](../index.html) intake | Box Form intake (AI dashcam stays as optional smart client) |
box-build/BOX_APP_DASHBOARD.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PotholeIQ β€” Box App Dashboard Build (complete)
2
+
3
+ Mirrors the ops-review console: hero metrics β†’ overview charts β†’ case lists β†’ case
4
+ detail with severity breakdown + photo. All blocks read the `Pothole Case`
5
+ (`enterprise/potholeCase`) metadata template.
6
+
7
+ **Block types available:** View (filtered metadata list), Chart, Item List, File or Folder, Form, App, AutoFiler, Shortcut.
8
+ **A "View" block = one saved Box metadata view** (its own columns/filter/sort, edited in the Box metadata view editor).
9
+
10
+ > ⚠️ Filters must use EXACT enum values:
11
+ > `status`: submitted Β· under review Β· assigned Β· `In progress` Β· resolved Β· rejected
12
+ > `severityLevel`: low Β· medium Β· high Β· critical
13
+ > `duplicateStatus`: new Β· `duplicate ` *(trailing space)* Β· pending
14
+
15
+ ---
16
+
17
+ ## PAGE 1 β€” "Operations Overview" (the landing page)
18
+
19
+ ### Section A β€” KPI row (Section type: **Grid**)
20
+ Four **Chart** blocks. Use chart type **Number/Metric** if available; else a small Donut.
21
+ | Block | Source | Measure | Filter |
22
+ |---|---|---|---|
23
+ | Cases In Queue | Pothole Case | Count | `status` = under review |
24
+ | Needs Attention | Pothole Case | Count | `status` β‰  resolved AND β‰  rejected |
25
+ | Priority Queue | Pothole Case | Count | `severityLevel` = critical OR high |
26
+ | Resolved | Pothole Case | Count | `status` = resolved |
27
+
28
+ ### Section B β€” distribution (Grid, 2 columns)
29
+ | Block | Type | Dimension (group by) | Measure |
30
+ |---|---|---|---|
31
+ | Cases by Severity | Donut | `severityLevel` | Count |
32
+ | Cases by Ward | Column | `ward` | Count |
33
+
34
+ ### Section C β€” the breakdown (Grid, 2 columns)
35
+ | Block | Type | Detail |
36
+ |---|---|---|
37
+ | **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.)* |
38
+ | Cases by Status | Column | group by `status`, Count (the pipeline) |
39
+
40
+ ---
41
+
42
+ ## PAGE 2 β€” "Case Queue" (the working lists)
43
+ One **View** block per saved view (create each, name it, pick Pothole Case, then set columns/filter/sort in the metadata view editor):
44
+
45
+ | View block | Filter | Sort | Columns |
46
+ |---|---|---|---|
47
+ | **All Cases** | none | submittedAt ↓ | caseId Β· status Β· severityLevel Β· severityScore0100 Β· ward Β· address Β· submittedAt |
48
+ | **Triage Queue** | status = under review | severityScore0100 ↓ | caseId Β· severityScore0100 Β· severityLevel Β· ward Β· address Β· priorNoticeScore Β· trafficScore Β· damageScore Β· weatherScore |
49
+ | **Duplicates** | duplicateStatus = `duplicate ` | β€” | caseId Β· linkedCaseIds Β· duplicateCount Β· severityScore0100 Β· ward |
50
+ | **In Progress** | status = assigned / In progress | β€” | caseId Β· assignedCrew Β· workOrderId Β· severityLevel Β· ward |
51
+ | **Resolved** | status = resolved | resolvedAt ↓ | caseId Β· resolvedAt Β· assignedCrew Β· severityLevel Β· ward |
52
+
53
+ ---
54
+
55
+ ## PAGE 3 β€” "Case Detail & Evidence"
56
+ - **File or Folder block** β†’ point at the `Potholes/` tree. Opening a case folder previews the
57
+ **initial pothole photo** + work order / closeout / audit docs inline.
58
+ - **The severity breakdown panel is native:** open any case folder β†’ Box's **metadata sidebar**
59
+ shows all `Pothole Case` fields, including the 4 breakdown **Scores + Details** and the total.
60
+ No extra block needed β€” that's the per-case breakdown.
61
+
62
+ Breakdown reads as: Prior Notice _/30 Β· Traffic _/25 Β· Damage _/25 Β· Weather _/10 β†’ Total /100 β†’ Level.
63
+
64
+ ---
65
+
66
+ ## Notes
67
+ - **No email from the App** β€” any dispatch/notify routes through Relay or the backend.
68
+ - **Views/charts are empty until cases carry `potholeCase` metadata** β€” backend seeds that.
69
+ - Build order: Page 1 charts β†’ Page 2 views β†’ Page 3 evidence. Then Save & Preview.
box-build/BOX_AUTOMATE_WORKFLOW.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Box Automate (Relay) β€” Pothole Workflow Build Guide
2
+
3
+ This guide builds the end-to-end pothole workflow as **Box Relay** flows (the no-code
4
+ workflow tool your setup calls "Box Automate"). It is written click-by-click but exact
5
+ button labels vary slightly by Box release β€” confirm the equivalent control in your tenant.
6
+
7
+ > **Division of labor:** Relay flows are built in the Box web console (Relay app). The code
8
+ > side (metadata template, enrichment Skill, Doc Gen template, backend callback) is in this
9
+ > repo. Each flow below names exactly which code asset it touches.
10
+
11
+ ---
12
+
13
+ ## Prerequisites (do these first, in order)
14
+
15
+ 1. **CCG connection live.** The service account must be able to read/write the case folders.
16
+ (Still pending your Box **Enterprise ID** so we can finish this in `data/config.json`.)
17
+ 2. **Metadata template created.** Create `potholeCase` from
18
+ [metadata-template.json](metadata-template.json) β€” Admin Console β†’ **Content** β†’
19
+ **Metadata** β†’ New Template (or POST it to `/2.0/metadata_templates/schema`).
20
+ 3. **Folder structure exists:**
21
+ - `Pothole Operations/Incoming Reports/` ← intake drop folder (your current intake
22
+ folder id `386202381022`)
23
+ - `Pothole Operations/Cases/` ← case folders get created here as `Ward-XYZ/Case-<id>/`
24
+ - `Pothole Operations/Reports/` ← Doc Gen output + audit reports
25
+ 4. **Backend reachable from Box cloud.** Relay's *HTTPS Request* action calls your backend.
26
+ Expose it with a tunnel (`ngrok http 3030` or `cloudflared`) and note the public URL,
27
+ e.g. `https://abc123.ngrok.io`. Your backend already exposes the callback endpoint
28
+ `POST /api/box/automate-update` (see [BACKEND_SETUP.md](../BACKEND_SETUP.md)).
29
+
30
+ ---
31
+
32
+ ## Flow 1 β€” Intake & Enrichment
33
+
34
+ **Goal:** when a new report lands, enrich it (severity, ward, duplicate, weather) and stamp
35
+ metadata. The hard math (GIS ward mapping, 50–100 m duplicate radius, deterministic severity,
36
+ weather) is NOT something Box AI can do β€” it runs in your enrichment endpoint, which Relay calls.
37
+
38
+ - **Trigger:** *When a file is uploaded* to `Pothole Operations/Incoming Reports`
39
+ (fire on the `*.json` sidecar so you enrich once per case, not once per photo).
40
+ - **Action 1 β€” HTTPS Request (enrichment):**
41
+ - Method: `POST`
42
+ - URL: `https://<your-public-url>/api/box/enrich` *(endpoint I will add to the backend)*
43
+ - Body (JSON): `{ "caseId": "{file name without extension}", "sidecarFileId": "{file id}" }`
44
+ - The endpoint computes severityScore/Level, ward/district/zone, duplicateStatus +
45
+ linkedCaseIds, weatherRisk and **writes them straight onto the `potholeCase` metadata**.
46
+ - **Action 2 β€” Apply Metadata** (`potholeCase`): set `status = Under Review` if not already set.
47
+ - **Action 3 β€” Create/Move:** move the photo + sidecar into `Cases/Ward-{ward}/Case-{caseId}/`.
48
+ *(Relay folder-from-metadata naming is limited; the enrichment endpoint can create the case
49
+ folder via API instead β€” your [box-folders.js](../box-folders.js) already does this.)*
50
+
51
+ > **Alternative (more native):** instead of the Relay HTTPS Request, register the enrichment
52
+ > endpoint as a **custom Box Skill** on the Incoming folder. Then enrichment fires
53
+ > automatically on upload and writes metadata cards β€” no Relay step needed for Flow 1.
54
+
55
+ ---
56
+
57
+ ## Flow 2 β€” Review & Approval Routing
58
+
59
+ **Goal:** notify the right ward engineer and capture approve / reject / adjust.
60
+
61
+ - **Trigger:** *When metadata field updates* β€” `potholeCase.status` becomes `Under Review`.
62
+ - **Condition (severity routing):**
63
+ - If `severityLevel = Critical` β†’ **immediate** path.
64
+ - Else β†’ standard path (optionally a scheduled digest flow).
65
+ - **Action β€” Assign Approval** to the ward engineer:
66
+ - Route by `ward` (use a Relay condition per ward β†’ assignee email, or a single group).
67
+ - Approver opens the case in Box web/mobile, reviews the photo + metadata + AI summary,
68
+ and can edit `severityLevel` (that's just editing a metadata field) before deciding.
69
+ - **On Approve:**
70
+ - Set `status = Assigned`, `supervisor = {approver}`.
71
+ - HTTPS Request β†’ `POST /api/box/automate-update` with
72
+ `{ "caseId": "...", "workflowStatus": "Assigned", "workflowStage": "crew_dispatch" }`
73
+ so your backend DB stays in sync.
74
+ - **On Reject:** set `status = Rejected`; HTTPS Request with `workflowStatus: "Rejected"`.
75
+
76
+ ---
77
+
78
+ ## Flow 3 β€” Work Order Generation (Box Doc Gen)
79
+
80
+ **Goal:** on approval, produce the work order / job sheet PDF into the case folder.
81
+
82
+ - **Trigger:** `potholeCase.status` becomes `Assigned`.
83
+ - **Action β€” Generate Document (Box Doc Gen):**
84
+ - Template: the Doc Gen template I'll create from [workorder_template.html](../workorder_template.html).
85
+ - Data source: the `potholeCase` metadata on the case folder (merge tags map 1:1 to the
86
+ field keys β€” see `04` mapping doc once created).
87
+ - Output: `Cases/Ward-{ward}/Case-{caseId}/Work-Order-{workOrderId}.pdf`.
88
+ - **Action β€” Apply Metadata:** write `workOrderId`.
89
+ - *(Optional)* HTTPS Request to push the work order into the city system of record.
90
+
91
+ ---
92
+
93
+ ## Flow 4 β€” Crew Closeout, Sign & Audit
94
+
95
+ **Goal:** field crew submits proof, signs, case is archived audit-ready.
96
+
97
+ - **Trigger:** a **Box Form** submission (crew closeout β€” built separately) lands a closeout
98
+ record / after-photo in the case folder, OR `status` becomes `In Progress` then closeout.
99
+ - **Action β€” Request Signature (Box Sign):** send the completion certificate for the crew
100
+ lead's e-signature (replaces the current typed-name field).
101
+ - **On signed:** set `status = Resolved`, `resolvedAt = {now}`; HTTPS Request β†’
102
+ `/api/box/automate-update` with `workflowStatus: "Resolved"` (your backend then auto-builds
103
+ the before/after + audit report β€” see `generateAndUploadAuditReport` in [server.js](../server.js)).
104
+ - **Action β€” Apply Governance:** apply a **retention policy / legal hold** to the case folder
105
+ (Box Governance) so the record is audit-ready with full version history.
106
+
107
+ ---
108
+
109
+ ## Field reference (Relay conditions & HTTPS payloads)
110
+
111
+ These are the `potholeCase` keys Relay reads/writes, and the matching `report.*` keys your
112
+ backend already uses (so the callback payload to `/api/box/automate-update` lines up):
113
+
114
+ | Metadata key (Box) | Backend `report.*` key | Used in flow |
115
+ |----------------------|-------------------------------|--------------|
116
+ | `status` | `workflowStatus` | all |
117
+ | `severityLevel` | `enrichment.severityLevel` | 1, 2 |
118
+ | `severityScore` | `enrichment.severityScore` | 1, 2 |
119
+ | `ward` / `district` | `ward` / `district` | 1, 2, 3 |
120
+ | `duplicateStatus` | `enrichment.duplicate.*` | 1 |
121
+ | `weatherRisk` | `enrichment.weather.riskLevel`| 1 |
122
+ | `assignedCrew` | `dispatchPlan.crewName` | 3, 4 |
123
+ | `workOrderId` | `workOrderId` | 3 |
124
+ | `resolvedAt` | `resolvedAt` | 4 |
125
+
126
+ **Backend callback contract** (already implemented): `POST /api/box/automate-update`
127
+ ```json
128
+ {
129
+ "caseId": "POT-20260604-Q2H0GR",
130
+ "workflowStage": "crew_dispatch",
131
+ "workflowStatus": "Assigned",
132
+ "assignedTo": "ward-12-crew@city.gov"
133
+ }
134
+ ```
135
+ If you set `BOX_AUTOMATE_SECRET` on the server, add the same value as an
136
+ `X-Box-Automate-Secret` header on every Relay HTTPS Request action.
137
+
138
+ ---
139
+
140
+ ## What I still owe you (code side)
141
+
142
+ - [ ] `POST /api/box/enrich` endpoint (wraps existing [enrichment.js](../enrichment.js) +
143
+ [gis.js](../gis.js)) that writes `potholeCase` metadata directly β€” for Flow 1.
144
+ - [ ] Box Doc Gen template + field-mapping doc β€” for Flow 3.
145
+ - [ ] (Optional) custom Box Skill manifest if you prefer the auto-on-upload enrichment path.
box-build/REVISED_WORKFLOW.md ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pothole Operations β€” Revised Workflow (Recommended)
2
+
3
+ This is the recommended end-to-end design after reviewing the codebase, the Box Relay
4
+ build, and the platform constraints. It is optimized to be **robust** (works even if Box AI
5
+ is disabled), **demoable now** (no tunnel, no external city system required), and **honest**
6
+ (every step does what it claims).
7
+
8
+ ---
9
+
10
+ ## Design decisions (the "why")
11
+
12
+ 1. **The backend computes enrichment AND writes Box metadata directly at upload.**
13
+ Severity, GPS→ward, duplicate, and weather are computed in our service the moment a report
14
+ is submitted, written into the JSON, and **also applied as Box metadata via the Box API
15
+ during upload.** This is the single most important change.
16
+ - βœ… The workflow does **not depend on Box AI / Box Extract being enabled** (which the Relay
17
+ screenshots showed as "no longer enabled"). If Extract is on, it's a confirming bonus.
18
+ - βœ… **No tunnel required** β€” the backend *pushes* complete data to Box; Box never calls back
19
+ just to enrich.
20
+ - βœ… Relay, the Box App, Doc Gen, and Sign all run natively on clean metadata.
21
+
22
+ 2. **Metadata is the contract.** Box's no-code tools branch and route on **metadata**, never on
23
+ raw file contents. The `potholeCase` template ([metadata-template.json](metadata-template.json))
24
+ is the shared schema everything reads.
25
+
26
+ 3. **Source of truth = backend; Box = content + metadata + workflow + audit.** The backend owns
27
+ live state; Box owns the authoritative files, the metadata mirror that drives Relay/App, and
28
+ the audit record. Every Box-side action (approval, closeout) calls back to the backend so the
29
+ two never drift.
30
+
31
+ 4. **Work order is generated in Box (Doc Gen), not an external city system.** Removes the only
32
+ hard external dependency. "Push to city system via REST" stays as an *optional future* step.
33
+
34
+ 5. **Citizens track status in the portal, never in Box.** No Box login for the public.
35
+
36
+ 6. **Six statuses only:** `Submitted` Β· `Under Review` Β· `Assigned` Β· `In Progress` Β· `Resolved` Β· `Rejected`.
37
+
38
+ ---
39
+
40
+ ## End-to-end flow
41
+
42
+ ```
43
+ CITIZEN PORTAL (web/mobile)
44
+ β”‚ photo(s) + location + reporter info + on-device AI size detection
45
+ β–Ό
46
+ BACKEND ── enrich at submission ───────────────────────────────┐
47
+ β”‚ β€’ GPS β†’ ward / district / zone (point-in-polygon GeoJSON)
48
+ β”‚ β€’ severity score + level (weighted formula)
49
+ β”‚ β€’ duplicate check 50–100 m (radius across open cases)
50
+ β”‚ β€’ weather risk (OpenWeather)
51
+ β”‚ β€’ assemble COMPLETE JSON
52
+ β–Ό
53
+ BOX ── backend uploads photo + complete JSON + applies metadata β”˜
54
+ β”‚ Incoming Reports/ (+ potholeCase metadata set on the file)
55
+ β–Ό
56
+ BOX RELAY (triggers on upload; runs on metadata)
57
+ β”‚
58
+ β”œβ”€β”€ duplicate? ──► link to existing case Β· boost severity Β· notify owner Β· STOP
59
+ β”‚
60
+ └── new ──► create Cases/Ward-{ward}/Case-{caseId}/ Β· status = Under Review
61
+ β”‚
62
+ β–Ό
63
+ REVIEW ── notify ward engineer (folder link Β· photo Β· AI summary Β· nearby cases)
64
+ β”‚ engineer approves / adjusts severity / rejects (Box web or mobile)
65
+ β”‚ β†’ callback to backend (keep state in sync)
66
+ β–Ό
67
+ APPROVED ── backend stamps workOrderId Β· BOX DOC GEN β†’ Work Order PDF
68
+ β”‚ status = Assigned
69
+ β–Ό
70
+ FIELD ── crew gets job sheet + mobile BOX FORM
71
+ β”‚ repair method Β· materials Β· labor hours Β· after-photo(s)
72
+ β”‚ status = In Progress
73
+ β–Ό
74
+ SIGN-OFF ── BOX SIGN (crew lead signature)
75
+ β”‚ status = Resolved Β· notify supervisor + risk mgmt
76
+ β”‚ backend generates before/after + audit report β†’ case folder
77
+ β–Ό
78
+ AUDIT ── Box Governance (retention / legal hold) + native version history
79
+ β”‚
80
+ CITIZEN PORTAL ◄─ status synced from backend (track.html) β€” no Box login
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Phase table
86
+
87
+ | # | Phase | Owner | Key actions | Output | Status |
88
+ |---|-------|-------|-------------|--------|--------|
89
+ | 1 | Intake | Portal | Photo, location (EXIF/device/manual), reporter, on-device AI size | Report posted | `Submitted` |
90
+ | 2 | Enrich | **Backend** | ward, severity, duplicate, weather β†’ complete JSON | Complete JSON | `Submitted` |
91
+ | 3 | Store | **Backend β†’ Box** | Upload photo + JSON; **apply `potholeCase` metadata** | Files + metadata in Box | `Submitted` |
92
+ | 4 | Route | Box Relay | Conditional Split on `duplicateStatus`; create case folder | Case folder / dup link | `Under Review` |
93
+ | 5 | Review | Ward engineer (Box) | Notify + approve/adjust/reject; callback to backend | Decision | `Under Review` β†’ `Rejected`/approved |
94
+ | 6 | Work order | Box Doc Gen | Stamp `workOrderId`; generate Job Sheet PDF | PDF in folder | `Assigned` |
95
+ | 7 | Field | Crew (Box Form) | Method, materials, labor, after-photo | Closeout record | `In Progress` |
96
+ | 8 | Sign-off | Box Sign | Signature; notify supervisor/risk; audit report | Signed cert + reports | `Resolved` |
97
+ | 9 | Transparency | Portal | Citizen looks up case | Status view | (read) |
98
+ | 10 | Audit | Box Governance | Retention, version history, access control | Audit-ready folder | (retained) |
99
+
100
+ ---
101
+
102
+ ## What runs where
103
+
104
+ **Backend (our service)** β€” the things Box can't do:
105
+ - GPS→ward (geometry), severity (scoring), duplicate (geo-radius), weather (external API)
106
+ - Assemble complete JSON, upload to Box, **apply Box metadata**
107
+ - Generate work order PDF source + before/after + audit reports (already implemented)
108
+ - Hold authoritative live state; expose status to the citizen portal
109
+
110
+ **Box (native, no custom code)** β€” configured in the console:
111
+ - Relay: trigger, duplicate split, folder creation, approval routing, notifications
112
+ - Doc Gen: Work Order / Job Sheet PDF
113
+ - Box Form: crew closeout Β· Box Sign: signature
114
+ - Governance + version history: audit
115
+ - (Optional) Box App: ops console + dashboards Β· Box AI: image summary if/when enabled
116
+
117
+ ---
118
+
119
+ ## Why this is the advisable version
120
+
121
+ - **Resilient to the disabled-AI problem** β€” the backend writes metadata directly, so Relay
122
+ works whether or not Box Extract/Agent is enabled.
123
+ - **No tunnel, no external system** β€” fully demoable as "files + metadata land in Box and Box
124
+ runs the workflow."
125
+ - **No drift** β€” single source of truth + callback on every Box action.
126
+ - **Honest on the call** β€” Box genuinely runs content, workflow, Doc Gen, Sign, and audit; our
127
+ service genuinely runs the geospatial scoring it feeds Box.
128
+
129
+ ---
130
+
131
+ ## Build checklist
132
+
133
+ **Done**
134
+ - [x] `potholeCase` metadata schema β€” [metadata-template.json](metadata-template.json)
135
+ - [x] GIS endpoint no longer 500s; serves local ward GeoJSON if present β€” [server.js](../server.js)
136
+ - [x] Credible authority routing defaults β€” [authority-routing.json](../authority-routing.json)
137
+
138
+ **Next (backend β€” I can build)**
139
+ - [ ] Enrich-at-submission: run ward + severity + duplicate + weather before the sidecar uploads
140
+ - [ ] Apply `potholeCase` Box metadata during upload (uses existing metadataScope/templateKey config)
141
+ - [ ] Work-order/approval endpoint for the Doc Gen + status sync
142
+ - [ ] Box Doc Gen template + field map from [workorder_template.html](../workorder_template.html)
143
+
144
+ **Next (Box console β€” your team)**
145
+ - [ ] Connect CCG (needs Enterprise ID + share intake folder with service account)
146
+ - [ ] Build the Relay flow per [BOX_AUTOMATE_WORKFLOW.md](BOX_AUTOMATE_WORKFLOW.md) (metadata-driven)
147
+ - [ ] Doc Gen template, Box Form, Box Sign, Governance policy
148
+ - [ ] (Optional) Box App console per [BOX_APP_CONSOLE.md](BOX_APP_CONSOLE.md)
149
+ - [ ] (Optional) drop real ward GeoJSON at `data/ward-boundaries.geojson`
box-build/WORKFLOW_ONE_BUILD.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Workflow One β€” Intake β†’ Dispatch (Relay build walkthrough)
2
+
3
+ Build this in the Box Relay canvas, top to bottom. After each node, click **+** and pick the
4
+ palette item named in **bold**. Workflow name: **Workflow One**.
5
+
6
+ **Prereqs:** `Pothole Case` metadata template exists Β· folders `Incoming Detections`,
7
+ `Cases/`, `Cases/Duplicates/`, `Cases/Rejected/` exist.
8
+
9
+ ---
10
+
11
+ ## TRIGGER β€” Flow Triggers β†’ File Uploaded
12
+ - **Folder:** `Incoming Detections`
13
+ - **Filter (recommended):** file name *contains* `_metadata.json`
14
+ β†’ fires once per case on the JSON sidecar, not the photo.
15
+
16
+ ## 1. AI Agents β†’ Extract Agent
17
+ - **Input file:** the triggering file.
18
+ - **Map to `Pothole Case` metadata:** `Case ID`, `Latitude`, `Longitude`, `Address`, `Ward`,
19
+ `District`, `Pothole Size`, `Severity Score`, `Severity Level`, `Duplicate Status`,
20
+ `Weather Risk`, `AI Review Status`.
21
+
22
+ ## 2. AI Agents β†’ Box Agent
23
+ - **Instruction:** "Read the case file and metadata; write a short plain-language case summary
24
+ and confirm the severity level."
25
+ - **Output to metadata:** `AI Summary`.
26
+
27
+ ## 3. Branching β†’ Conditional Split *(name it "Duplicate?")*
28
+ - **Condition:** `Pothole Case` β€Ί `Duplicate Status` **is** `duplicate`
29
+ - Creates two paths: **Duplicate** (true) and **New** (Else).
30
+
31
+ ---
32
+
33
+ ### β–Έ DUPLICATE path
34
+ **4a. Outcomes β†’ File Action β†’ Move** β†’ `Cases/Duplicates/`
35
+ **5a. Outcomes β†’ Add Metadata** β†’ `Status = Under Review`
36
+ β†’ *path ends (no new work order; it's linked via `Linked Case IDs`).*
37
+
38
+ ---
39
+
40
+ ### β–Έ NEW path
41
+ **4b. Outcomes β†’ Folder Action β†’ Create Folder**
42
+ - **Name (use variables):** `Case-{Case ID}`
43
+ - **Parent path:** `Cases / Ward-{Ward}`
44
+
45
+ **5b. Outcomes β†’ File Action β†’ Move** β†’ into the new `Case-{Case ID}` folder.
46
+
47
+ **6. Outcomes β†’ Add Metadata** β†’ `Status = Under Review`.
48
+
49
+ **7. Outcomes β†’ Send Notification**
50
+ - **To:** ward engineer (route by `Ward`, or a fixed group for the demo).
51
+ - **Message:** include `{AI Summary}`, `{Severity Level}`, and the case folder link.
52
+
53
+ **8. Outcomes β†’ Task Action** *(approval)*
54
+ - **Title:** `Pothole Repair Work Order – {Case ID}`
55
+ - **Assignee:** ward engineer
56
+ - **Instructions:** "Approve, adjust severity, or reject."
57
+
58
+ **9. Branching β†’ Conditional Split** on the task result β†’ **Approved** / **Rejected**
59
+
60
+ #### β–Ή APPROVED
61
+ **10. Outcomes β†’ HTTPS Request**
62
+ - **Method:** POST
63
+ - **URL:** `https://<backend-url>/api/box/workorder` *(endpoint I'll provide)*
64
+ - **Body:** `{ "caseId": "{Case ID}" }`
65
+ - Backend stamps `Work Order ID` + generates the **Box Doc Gen** Job Sheet PDF into the folder.
66
+
67
+ **11. Outcomes β†’ Add Metadata** β†’ `Status = Assigned`, `Work Order ID = {returned id}`.
68
+ β†’ **End of Workflow One.**
69
+
70
+ #### β–Ή REJECTED
71
+ **10r. Outcomes β†’ Add Metadata** β†’ `Status = Rejected`
72
+ **11r. Outcomes β†’ File Action β†’ Move** β†’ `Cases/Rejected/`
73
+ **12r. Outcomes β†’ Send Notification** β†’ reporter (if contact on file).
74
+
75
+ ---
76
+
77
+ ## Notes
78
+ - **Doc Gen is not a Relay action** in your palette β€” that's why step 10 is an **HTTPS Request**
79
+ to the backend (which runs Doc Gen). It needs the backend reachable from Box (a tunnel for the
80
+ demo). If the backend isn't up yet, **stop Workflow One at step 11 (`Status = Assigned`)** and
81
+ generate the work order later β€” the flow is still complete and demoable.
82
+ - **Guard against loops:** the Add Metadata steps write `Status`; make sure no trigger in this
83
+ flow is "metadata updated," or it can re-fire. This flow triggers on *file upload* only, so
84
+ you're safe.
85
+ - **Crew closeout, Box Sign, resolve** all live in **Workflow Two** (triggered by the Box Form),
86
+ not here.
box-build/WORKFLOW_TWO_BUILD.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Workflow Two β€” Crew Reporting & Closeout
2
+
3
+ Triggered by the crew **Box Form** submission. Build the Form first (it's the trigger), then the flow.
4
+
5
+ **Prereqs:** Box Form "Crew Closeout" built Β· folder `Cases/Closeouts/` (where the form saves) Β·
6
+ Box Sign enabled Β· Box Governance retention policy on `Cases/` (set once, separately).
7
+
8
+ ---
9
+
10
+ ## Step A β€” Box Form: "Crew Closeout" (build this first)
11
+
12
+ Fields:
13
+ | Field | Type | Notes |
14
+ |---|---|---|
15
+ | Case ID | Text (required) | crew enters/scans the case it belongs to |
16
+ | Repair Method | Dropdown | Cold patch / Hot mix / Full-depth |
17
+ | Materials Used | Text | |
18
+ | Labor Hours | Number | |
19
+ | After Photo | File upload (required) | the "after" proof |
20
+ | Crew Name | Text | |
21
+ | Notes | Long text | optional |
22
+
23
+ - **Save destination:** `Cases/Closeouts/` (a single intake folder is simplest).
24
+ - If the Form can **map answers to metadata**, map them to the matching Pothole Case fields.
25
+
26
+ ---
27
+
28
+ ## Step B β€” Workflow Two (Relay)
29
+
30
+ **TRIGGER:** Box Form submitted β€” "Crew Closeout"
31
+ *(If there's no native Form trigger, use **File Uploaded β†’ `Cases/Closeouts/`** instead.)*
32
+
33
+ **1. AI Agents β†’ Extract Agent** *(only if the Form didn't write metadata directly)*
34
+ - Pull form values β†’ Pothole Case metadata: `repairMethod`, `materialsUsed`, `laborHours`, `crewName`, `afterPhotoFileId`.
35
+
36
+ **2. Outcomes β†’ Add Metadata**
37
+ - `Status = In Progress` + the closeout fields above.
38
+
39
+ **3. Outcomes β†’ Request Signature (Box Sign)**
40
+ - Sign the **completion certificate / closeout report**.
41
+ - ⚠️ 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.
42
+
43
+ **4. Branch on the signature outcome β†’ Completed / Declined / Expired / Cancelled**
44
+
45
+ ### β–Ή Completed
46
+ - **Add Metadata** β†’ `Status = Resolved`, `resolvedAt = {now}`
47
+ - **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)*
48
+ - **Send Notification** β†’ supervisor + risk management (Box users)
49
+ - *(Optional)* **File Action β†’ Apply Classification** β†’ "Closed – Retain" (Box Shield/Governance)
50
+
51
+ ### β–Ή Declined
52
+ - **Send Notification** β†’ re-route / fix and resubmit.
53
+
54
+ ### β–Ή Expired
55
+ - **Send Notification** β†’ remind the signer.
56
+
57
+ ### β–Ή Cancelled
58
+ - **Add Metadata** β†’ hold / note.
59
+
60
+ ---
61
+
62
+ ## Notes
63
+ - **Box Governance** (retention / legal hold) is a **policy set once on the `Cases/` folder** β€” it auto-applies, it's not a Relay node.
64
+ - **Citizen** sees `Resolved` via the **portal** (no Box notification to them).
65
+ - **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.
box-build/add-breakdown-fields.sh ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Adds the 4 severity sub-score floats + 4 detail strings to the potholeCase
3
+ # metadata template so the breakdown can be shown in the Box App dashboard.
4
+ # Additive only β€” does not touch existing fields or Relay flows.
5
+ # Usage: bash add-breakdown-fields.sh <FRESH_DEV_TOKEN>
6
+ set -euo pipefail
7
+ TOKEN="${1:?Pass a fresh Box dev token as the first argument}"
8
+
9
+ curl -s -w "\nHTTP %{http_code}\n" \
10
+ -X PUT "https://api.box.com/2.0/metadata_templates/enterprise/potholeCase/schema" \
11
+ -H "Authorization: Bearer $TOKEN" \
12
+ -H "Content-Type: application/json-patch+json" \
13
+ -d '[
14
+ {"op":"addField","data":{"type":"float","key":"priorNoticeScore","displayName":"Prior Notice Score (0-30)"}},
15
+ {"op":"addField","data":{"type":"float","key":"trafficScore","displayName":"Traffic Score (0-25)"}},
16
+ {"op":"addField","data":{"type":"float","key":"damageScore","displayName":"Damage Score (0-25)"}},
17
+ {"op":"addField","data":{"type":"float","key":"weatherScore","displayName":"Weather Score (0-10)"}},
18
+ {"op":"addField","data":{"type":"string","key":"priorNoticeDetail","displayName":"Prior Notice Detail"}},
19
+ {"op":"addField","data":{"type":"string","key":"trafficDetail","displayName":"Traffic Detail"}},
20
+ {"op":"addField","data":{"type":"string","key":"damageDetail","displayName":"Damage Detail"}},
21
+ {"op":"addField","data":{"type":"string","key":"weatherDetail","displayName":"Weather Detail"}}
22
+ ]'
box-build/metadata-template.json ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_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.",
3
+ "scope": "enterprise",
4
+ "templateKey": "potholeCase",
5
+ "displayName": "Pothole Case",
6
+ "hidden": false,
7
+ "copyInstanceOnItemCopy": true,
8
+ "fields": [
9
+ { "type": "string", "key": "caseId", "displayName": "Case ID" },
10
+ {
11
+ "type": "enum",
12
+ "key": "status",
13
+ "displayName": "Status",
14
+ "options": [
15
+ { "key": "Submitted" },
16
+ { "key": "Under Review" },
17
+ { "key": "Assigned" },
18
+ { "key": "In Progress" },
19
+ { "key": "Resolved" },
20
+ { "key": "Rejected" }
21
+ ]
22
+ },
23
+ { "type": "date", "key": "submittedAt", "displayName": "Submitted At" },
24
+ { "type": "string", "key": "address", "displayName": "Address" },
25
+ { "type": "string", "key": "ward", "displayName": "Ward" },
26
+ { "type": "string", "key": "district", "displayName": "District" },
27
+ { "type": "string", "key": "maintenanceZone", "displayName": "Maintenance Zone" },
28
+ { "type": "float", "key": "latitude", "displayName": "Latitude" },
29
+ { "type": "float", "key": "longitude", "displayName": "Longitude" },
30
+ { "type": "float", "key": "severityScore", "displayName": "Severity Score (0-100)" },
31
+ {
32
+ "type": "enum",
33
+ "key": "severityLevel",
34
+ "displayName": "Severity Level",
35
+ "options": [
36
+ { "key": "Low" },
37
+ { "key": "Medium" },
38
+ { "key": "High" },
39
+ { "key": "Critical" }
40
+ ]
41
+ },
42
+ {
43
+ "type": "enum",
44
+ "key": "potholeSize",
45
+ "displayName": "Pothole Size",
46
+ "options": [
47
+ { "key": "Small" },
48
+ { "key": "Medium" },
49
+ { "key": "Large" }
50
+ ]
51
+ },
52
+ {
53
+ "type": "enum",
54
+ "key": "duplicateStatus",
55
+ "displayName": "Duplicate Status",
56
+ "options": [
57
+ { "key": "new" },
58
+ { "key": "duplicate" },
59
+ { "key": "pending" }
60
+ ]
61
+ },
62
+ { "type": "float", "key": "duplicateCount", "displayName": "Duplicate Count" },
63
+ { "type": "string", "key": "linkedCaseIds", "displayName": "Linked Case IDs" },
64
+ {
65
+ "type": "enum",
66
+ "key": "weatherRisk",
67
+ "displayName": "Weather Risk",
68
+ "options": [
69
+ { "key": "low" },
70
+ { "key": "medium" },
71
+ { "key": "high" }
72
+ ]
73
+ },
74
+ {
75
+ "type": "enum",
76
+ "key": "aiReviewStatus",
77
+ "displayName": "AI Review Status",
78
+ "options": [
79
+ { "key": "auto_confirmed" },
80
+ { "key": "manual_review" },
81
+ { "key": "manual_confirmed" },
82
+ { "key": "manual_rejected" }
83
+ ]
84
+ },
85
+ { "type": "string", "key": "aiSummary", "displayName": "AI Summary" },
86
+ { "type": "string", "key": "assignedCrew", "displayName": "Assigned Crew" },
87
+ { "type": "string", "key": "workOrderId", "displayName": "Work Order ID" },
88
+ { "type": "string", "key": "supervisor", "displayName": "Supervisor / Ward Engineer" },
89
+ { "type": "date", "key": "resolvedAt", "displayName": "Resolved At" },
90
+ { "type": "string", "key": "reporterContact", "displayName": "Reporter Contact" },
91
+ { "type": "string", "key": "boxCaseFolderId", "displayName": "Case Folder ID" }
92
+ ]
93
+ }
box-build/relay-flow-diagram.html ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Pothole Portal β€” Box Automate (Relay) Flows</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: 'Segoe UI', Inter, system-ui, Arial, sans-serif;
11
+ color: #1f2733;
12
+ background-color: #fbfbfc;
13
+ background-image: radial-gradient(#e3e6ee 1px, transparent 1px);
14
+ background-size: 22px 22px;
15
+ padding: 36px 16px 70px;
16
+ }
17
+ .wrap { max-width: 760px; margin: 0 auto; }
18
+ h1 { font-size: 22px; font-weight: 800; text-align: center; color: #0b1f3a; }
19
+ .wname { text-align:center; font-size:12px; color:#69728a; margin-top:6px; }
20
+ .wname b { color:#0b1f3a; }
21
+
22
+ .banner {
23
+ margin: 16px auto 10px; max-width: 620px; background: #eaf2fd; border: 1px solid #b6d3f5;
24
+ color: #134a8e; border-radius: 9px; padding: 9px 14px; font-size: 12px; text-align: center;
25
+ }
26
+ .banner b { color:#0b3c78; }
27
+
28
+ .flowhdr {
29
+ display:flex; align-items:center; gap:10px; justify-content:center; margin: 30px auto 16px;
30
+ }
31
+ .flowhdr .pill {
32
+ font-size: 12px; font-weight: 800; letter-spacing:.04em; color:#0b1f3a;
33
+ background:#fff; border:1px solid #d3d9e6; border-radius: 24px; padding: 6px 16px;
34
+ box-shadow: 0 1px 3px rgba(15,30,60,.06);
35
+ }
36
+ .flowhdr .trig { font-size: 11px; color:#69728a; }
37
+
38
+ .col { display: flex; flex-direction: column; align-items: center; }
39
+
40
+ .node {
41
+ width: 380px; max-width: 100%; background: #fff; border: 1px solid #e4e7ef;
42
+ border-radius: 11px; box-shadow: 0 1px 3px rgba(15,30,60,.07);
43
+ padding: 11px 13px; display: flex; gap: 11px; align-items: flex-start;
44
+ }
45
+ .ico {
46
+ flex: 0 0 30px; width: 30px; height: 30px; border-radius: 8px; color: #fff;
47
+ display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700;
48
+ }
49
+ .node .title { font-size: 13.5px; font-weight: 700; line-height: 1.25; }
50
+ .node .kind { font-size: 10.5px; color:#969db0; font-weight:600; margin-top:1px; }
51
+ .node .desc { font-size: 11.5px; color:#5d6678; margin-top: 5px; line-height: 1.35; }
52
+ .aibadge { display:inline-block; margin-left:6px; font-size:9px; font-weight:800; vertical-align:middle;
53
+ color:#7c2bd6; background:#f3e8ff; border:1px solid #ddbdf7; border-radius:10px; padding:0 6px; letter-spacing:.05em; }
54
+ .note { display:inline-block; margin-top:6px; font-size:10px; font-weight:600; color:#8a5a0a;
55
+ background:#fff6e3; border:1px solid #f1c878; border-radius:8px; padding:2px 8px; }
56
+ .repl { display:inline-block; margin-top:6px; font-size:10px; font-weight:700; color:#0c6b46;
57
+ background:#e7f7ef; border:1px solid #b6e3cd; border-radius:20px; padding:1px 8px; }
58
+
59
+ /* icon colors */
60
+ .c-trigger { background:#0061d5; }
61
+ .c-extract { background:#a855f7; }
62
+ .c-agent { background:#d946ef; }
63
+ .c-split { background:#64748b; }
64
+ .c-folder { background:#0a73e0; }
65
+ .c-file { background:#3b8ee8; }
66
+ .c-meta { background:#7c3aed; }
67
+ .c-http { background:#0e9f6e; }
68
+ .c-notify { background:#e8920c; }
69
+ .c-task { background:#2563eb; }
70
+ .c-form { background:#0891b2; }
71
+ .c-sign { background:#9333ea; }
72
+ .c-gov { background:#475569; }
73
+ .c-end { background:#94a3b8; }
74
+
75
+ .conn { width: 2px; height: 22px; background: #c4cad8; }
76
+ .plus {
77
+ width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid #c4cad8; color:#9aa2b4;
78
+ display:flex; align-items:center; justify-content:center; font-size:13px; line-height:1; background:#fff;
79
+ }
80
+
81
+ .branchbar { width: 100%; }
82
+ .branchbar .hline { height: 2px; background: #c4cad8; margin: 0 auto; }
83
+ .cols { display: grid; gap: 14px; }
84
+ .cols.two { grid-template-columns: 1fr 1fr; }
85
+ .cols.four { grid-template-columns: repeat(4, 1fr); }
86
+ .bcol { display:flex; flex-direction:column; align-items:center; }
87
+ .tick { width:2px; height:14px; background:#c4cad8; }
88
+ .blabel {
89
+ font-size: 10.5px; font-weight: 700; letter-spacing:.03em; color:#54607a;
90
+ background:#eef1f7; border:1px solid #d8deea; border-radius: 20px; padding: 2px 11px; margin-bottom: 8px;
91
+ }
92
+ .blabel.yes { color:#0c6b46; background:#e7f7ef; border-color:#b6e3cd; }
93
+ .blabel.no { color:#ad2222; background:#fdeaea; border-color:#f1bcbc; }
94
+
95
+ .node.sm { width: 100%; padding: 9px 10px; }
96
+ .node.sm .ico { flex-basis:24px; width:24px; height:24px; font-size:12px; border-radius:6px; }
97
+ .node.sm .title { font-size: 12px; }
98
+ .node.sm .desc { font-size: 10.5px; margin-top:3px; }
99
+
100
+ .endpill { font-size:10.5px; font-weight:700; color:#64748b; background:#eef1f7;
101
+ border:1px solid #d8deea; border-radius:20px; padding:3px 12px; margin-top:8px; }
102
+
103
+ .divider { display:flex; align-items:center; gap:12px; width:100%; max-width:560px; margin:8px auto; color:#8a93a6; font-size:11.5px; font-weight:600; }
104
+ .divider::before, .divider::after { content:""; flex:1; height:1px; background:#d3d9e6; }
105
+ </style>
106
+ </head>
107
+ <body>
108
+ <div class="wrap">
109
+ <h1>Box Automate (Relay) β€” Pothole Portal</h1>
110
+ <div class="wname">Two flows Β· <b>Box Extract</b> + <b>Box Agent</b> enabled Β· backend supplies the GIS &amp; weather data AI can't compute</div>
111
+ <div class="banner"><b>Full Box-native build:</b> AI agents do extraction, scoring &amp; summary; a lightweight data lookup provides GPS→ward &amp; weather inputs.</div>
112
+
113
+ <!-- ============ FLOW 1 ============ -->
114
+ <div class="flowhdr">
115
+ <span class="pill">FLOW 1 β€” Intake β†’ Dispatch</span>
116
+ <span class="trig">triggered by file upload</span>
117
+ </div>
118
+
119
+ <div class="col">
120
+
121
+ <div class="node">
122
+ <div class="ico c-trigger">⚑</div>
123
+ <div><div class="title">File Uploaded</div>
124
+ <div class="kind">TRIGGER Β· Incoming Reports</div>
125
+ <div class="desc">Citizen report lands: photo + <b>_metadata.json</b>.</div></div>
126
+ </div>
127
+ <div class="conn"></div>
128
+
129
+ <div class="node">
130
+ <div class="ico c-extract">⊞</div>
131
+ <div><div class="title">Extract Agent <span class="aibadge">AI</span></div>
132
+ <div class="kind">BOX EXTRACT Β· file β†’ metadata</div>
133
+ <div class="desc">Reads the JSON and writes the <b>potholeCase</b> metadata: caseId, location, AI size, road score.</div></div>
134
+ </div>
135
+ <div class="conn"></div>
136
+
137
+ <div class="node">
138
+ <div class="ico c-agent">✦</div>
139
+ <div><div class="title">Box Agent β€” Enrichment &amp; Scoring <span class="aibadge">AI</span></div>
140
+ <div class="kind">BOX AGENT Β· reasoning</div>
141
+ <div class="desc">Evaluates severity score &amp; level, writes a case summary, and reasons over duplicate likelihood.</div>
142
+ <span class="note">GPS→ward &amp; weather supplied via HTTPS data lookup (AI can't compute geometry)</span></div>
143
+ </div>
144
+ <div class="conn"></div>
145
+
146
+ <div class="node">
147
+ <div class="ico c-split">β—‡</div>
148
+ <div><div class="title">Duplicate Check</div>
149
+ <div class="kind">CONDITIONAL SPLIT Β· metadata: duplicateStatus</div>
150
+ <div class="desc">Within 50–100 m of an open case?</div></div>
151
+ </div>
152
+
153
+ <div class="branchbar">
154
+ <div class="hline" style="width:70%;"></div>
155
+ <div class="cols two">
156
+ <div class="bcol">
157
+ <div class="tick"></div><div class="blabel">DUPLICATE</div>
158
+ <div class="node sm"><div class="ico c-file">β–€</div>
159
+ <div><div class="title">Link to Parent Case</div><div class="kind">FILE ACTION + ADD METADATA</div>
160
+ <div class="desc">Move to Duplicates Β· boost severity.</div></div></div>
161
+ <div class="endpill">END Β· no new work order</div>
162
+ </div>
163
+ <div class="bcol">
164
+ <div class="tick"></div><div class="blabel">NEW REPORT</div>
165
+ <div class="node sm"><div class="ico c-folder">β–£</div>
166
+ <div><div class="title">Create Case Folder</div><div class="kind">FOLDER ACTION</div>
167
+ <div class="desc">Potholes / Ward-{ward} / Case-{caseId}</div></div></div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+ <div class="conn"></div>
173
+ <div class="node"><div class="ico c-meta">M</div>
174
+ <div><div class="title">Set Status &amp; Move Into Folder</div>
175
+ <div class="kind">ADD METADATA + FILE ACTION</div>
176
+ <div class="desc">status = <b>Under Review</b>.</div></div></div>
177
+ <div class="conn"></div>
178
+
179
+ <div class="node"><div class="ico c-notify">βœ‰</div>
180
+ <div><div class="title">Notify Ward Engineer</div>
181
+ <div class="kind">SEND NOTIFICATION</div>
182
+ <div class="desc">Folder link Β· photo Β· AI summary Β· severity Β· nearby cases.</div></div></div>
183
+ <div class="conn"></div>
184
+
185
+ <div class="node"><div class="ico c-task">βœ“</div>
186
+ <div><div class="title">Review &amp; Approval</div>
187
+ <div class="kind">TASK ACTION Β· Approval from authority</div>
188
+ <div class="desc">Approve Β· adjust severity Β· or reject (web / mobile).</div></div></div>
189
+
190
+ <div class="branchbar">
191
+ <div class="hline" style="width:70%;"></div>
192
+ <div class="cols two">
193
+ <div class="bcol">
194
+ <div class="tick"></div><div class="blabel no">REJECTED</div>
195
+ <div class="node sm"><div class="ico c-meta">M</div>
196
+ <div><div class="title">Reject &amp; Archive</div><div class="kind">ADD METADATA + FILE ACTION</div>
197
+ <div class="desc">status = Rejected Β· notify reporter.</div></div></div>
198
+ <div class="endpill">END</div>
199
+ </div>
200
+ <div class="bcol">
201
+ <div class="tick"></div><div class="blabel yes">APPROVED</div>
202
+ <div class="node sm"><div class="ico c-http">⇄</div>
203
+ <div><div class="title">Generate Work Order</div><div class="kind">HTTPS β†’ backend β†’ Box Doc Gen</div>
204
+ <div class="desc">Stamp workOrderId Β· Job Sheet PDF into folder.</div>
205
+ <span class="repl">No city-system API</span></div></div>
206
+ <div class="tick"></div>
207
+ <div class="node sm"><div class="ico c-meta">M</div>
208
+ <div><div class="title">status = Assigned</div><div class="kind">ADD METADATA</div></div></div>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+
214
+ <div class="flowhdr" style="margin-top:18px;"><span class="trig">β€” job sheet sent Β· crew repairs on site β€”</span></div>
215
+
216
+ <!-- ============ FLOW 2 ============ -->
217
+ <div class="flowhdr">
218
+ <span class="pill">FLOW 2 β€” Crew Reporting &amp; Closeout</span>
219
+ <span class="trig">triggered by Box Form submission</span>
220
+ </div>
221
+
222
+ <div class="col">
223
+
224
+ <div class="node">
225
+ <div class="ico c-form">β–¦</div>
226
+ <div><div class="title">Crew Submits Box Form (mobile)</div>
227
+ <div class="kind">TRIGGER Β· Box Form Β· on-site</div>
228
+ <div class="desc">Repair method (cold patch / hot mix / full-depth) Β· materials Β· labor hours Β· <b>after-photo(s)</b> β†’ case folder.</div></div>
229
+ </div>
230
+ <div class="conn"></div>
231
+
232
+ <div class="node"><div class="ico c-meta">M</div>
233
+ <div><div class="title">Record Closeout Data</div>
234
+ <div class="kind">ADD METADATA</div>
235
+ <div class="desc">status = <b>In Progress</b> Β· materials, labor, after-photo IDs written to the case.</div></div></div>
236
+ <div class="conn"></div>
237
+
238
+ <div class="node"><div class="ico c-sign">✎</div>
239
+ <div><div class="title">Request Signature</div>
240
+ <div class="kind">REQUEST SIGNATURE Β· Box Sign</div>
241
+ <div class="desc">Crew lead signs the completion certificate.</div></div></div>
242
+
243
+ <div class="branchbar">
244
+ <div class="hline" style="width:86%;"></div>
245
+ <div class="cols four">
246
+ <div class="bcol"><div class="tick"></div><div class="blabel yes">COMPLETED</div>
247
+ <div class="node sm"><div class="ico c-http">⇄</div>
248
+ <div><div class="title">Resolve Case</div><div class="kind">HTTPS β†’ backend</div>
249
+ <div class="desc">status = Resolved Β· audit + before/after report.</div></div></div></div>
250
+ <div class="bcol"><div class="tick"></div><div class="blabel no">DECLINED</div>
251
+ <div class="node sm"><div class="ico c-notify">βœ‰</div>
252
+ <div><div class="title">Re-route</div><div class="kind">SEND NOTIFICATION</div></div></div></div>
253
+ <div class="bcol"><div class="tick"></div><div class="blabel">EXPIRED</div>
254
+ <div class="node sm"><div class="ico c-notify">βœ‰</div>
255
+ <div><div class="title">Remind</div><div class="kind">SEND NOTIFICATION</div></div></div></div>
256
+ <div class="bcol"><div class="tick"></div><div class="blabel">CANCELLED</div>
257
+ <div class="node sm"><div class="ico c-meta">M</div>
258
+ <div><div class="title">Hold</div><div class="kind">ADD METADATA</div></div></div></div>
259
+ </div>
260
+ </div>
261
+
262
+ <div class="conn"></div>
263
+ <div class="node"><div class="ico c-notify">βœ‰</div>
264
+ <div><div class="title">Notify Supervisor + Risk Management</div>
265
+ <div class="kind">SEND NOTIFICATION</div>
266
+ <div class="desc">Citizen portal status updated to β€œResolved”.</div></div></div>
267
+ <div class="conn"></div>
268
+
269
+ <div class="node"><div class="ico c-gov">G</div>
270
+ <div><div class="title">Audit-Ready Case Record</div>
271
+ <div class="kind">BOX GOVERNANCE</div>
272
+ <div class="desc">Retention / legal hold Β· full version history Β· activity log Β· access control.</div></div></div>
273
+
274
+ </div>
275
+ </div>
276
+ </body>
277
+ </html>
box-build/revised-workflow-diagram.html ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Pothole Operations β€” Revised Workflow</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: 'Segoe UI', Inter, system-ui, Arial, sans-serif;
11
+ background: #f4f6fb;
12
+ color: #1f2733;
13
+ padding: 40px 16px 64px;
14
+ line-height: 1.4;
15
+ }
16
+ .wrap { max-width: 860px; margin: 0 auto; }
17
+ h1 { font-size: 26px; font-weight: 800; text-align: center; color: #0b1f3a; }
18
+ .subtitle { text-align: center; color: #5b6679; font-size: 14px; margin-top: 6px; }
19
+
20
+ .legend { display: flex; gap: 18px; justify-content: center; margin: 18px 0 28px; flex-wrap: wrap; }
21
+ .legend span { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: #44506a; }
22
+ .dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
23
+
24
+ .phase {
25
+ text-align: center; font-size: 12px; font-weight: 700; letter-spacing: .08em;
26
+ text-transform: uppercase; color: #7a8499; margin: 26px 0 14px;
27
+ border-top: 1px dashed #c4ccda; padding-top: 12px;
28
+ }
29
+
30
+ .row { display: grid; grid-template-columns: 1fr minmax(280px, 360px) 1fr; align-items: center; gap: 12px; }
31
+ .row .center { grid-column: 2; }
32
+
33
+ .node {
34
+ border-radius: 12px; padding: 14px 18px; text-align: center;
35
+ border: 1px solid; box-shadow: 0 2px 6px rgba(15,30,60,.06);
36
+ }
37
+ .node .t { font-weight: 700; font-size: 15px; }
38
+ .node .d { font-size: 12.5px; margin-top: 4px; opacity: .92; }
39
+
40
+ .portal { background: #e7f7ef; border-color: #9ae0c0; color: #0c6b46; }
41
+ .backend { background: #f1ecfe; border-color: #c3b1f5; color: #4c2aa3; }
42
+ .box { background: #e9f2fd; border-color: #a9cdf6; color: #0a4a9c; }
43
+ .review { background: #e7f7ef; border-color: #9ae0c0; color: #0c6b46; }
44
+ .crew { background: #fdeaea; border-color: #f4b4b4; color: #ad2222; }
45
+ .audit { background: #eef2f8; border-color: #ccd6e6; color: #3b475e; }
46
+ .decision {
47
+ background: #fff6e3; border: 1px solid #f1c878; color: #92600c;
48
+ border-radius: 12px; padding: 14px 18px; text-align: center; font-weight: 700; font-size: 15px;
49
+ box-shadow: 0 2px 6px rgba(15,30,60,.06);
50
+ }
51
+
52
+ .note {
53
+ background: #fff; border: 1px solid #e1e7f0; border-radius: 9px;
54
+ padding: 9px 11px; font-size: 11.5px; color: #5b6679; box-shadow: 0 1px 3px rgba(15,30,60,.05);
55
+ }
56
+ .note b { color: #34405a; display: block; margin-bottom: 3px; font-size: 11px; }
57
+ .note.left { justify-self: end; } .note.right { justify-self: start; }
58
+
59
+ .status {
60
+ display: inline-block; margin-top: 8px; font-size: 11px; font-weight: 700;
61
+ background: rgba(255,255,255,.6); border-radius: 20px; padding: 2px 10px; letter-spacing: .03em;
62
+ }
63
+
64
+ .conn { width: 2px; height: 26px; background: #b9c3d4; margin: 4px auto; }
65
+ .conn.sm { height: 16px; }
66
+
67
+ .branch { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; max-width: 720px; margin: 0 auto; }
68
+ .branch .lbl { text-align: center; font-size: 11px; font-weight: 700; color: #7a8499; margin-bottom: 6px; letter-spacing:.05em; }
69
+ .tag-yes { color: #ad7100; } .tag-no { color: #0a4a9c; }
70
+
71
+ .footer-note {
72
+ margin: 26px auto 0; max-width: 720px; background: #fff; border: 1px dashed #b9c3d4;
73
+ border-radius: 10px; padding: 12px 16px; font-size: 12.5px; color: #44506a; text-align: center;
74
+ }
75
+ .footer-note b { color: #0b1f3a; }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div class="wrap">
80
+ <h1>Pothole Operations β€” Revised Workflow</h1>
81
+ <div class="subtitle">Backend computes the geospatial scoring Β· Box runs content, workflow, documents, signature &amp; audit</div>
82
+
83
+ <div class="legend">
84
+ <span><i class="dot" style="background:#0c6b46"></i> Citizen portal</span>
85
+ <span><i class="dot" style="background:#4c2aa3"></i> Our backend service</span>
86
+ <span><i class="dot" style="background:#0a4a9c"></i> Box (native)</span>
87
+ <span><i class="dot" style="background:#92600c"></i> Decision</span>
88
+ <span><i class="dot" style="background:#ad2222"></i> Field crew</span>
89
+ </div>
90
+
91
+ <!-- PHASE 1 -->
92
+ <div class="phase">Phase 1 β€” Citizen Intake</div>
93
+ <div class="row">
94
+ <div class="note left"><b>Form fields</b>Name / contact Β· Location or address Β· Photo upload(s)</div>
95
+ <div class="center node portal">
96
+ <div class="t">Citizen Submission Portal</div>
97
+ <div class="d">Web form Β· mobile app Β· 311 integration</div>
98
+ <span class="status">Submitted</span>
99
+ </div>
100
+ <div class="note right"><b>Auto-captured</b>GPS coordinates Β· Timestamp (UTC) Β· On-device AI size detection</div>
101
+ </div>
102
+
103
+ <div class="conn"></div>
104
+
105
+ <!-- PHASE 2 -->
106
+ <div class="phase">Phase 2 β€” Backend Enrichment (at submission)</div>
107
+ <div class="row">
108
+ <div class="note left"><b>Severity inputs</b>Damage size / depth Β· Road classification Β· Prior 311 notices Β· Weather forecast</div>
109
+ <div class="center node backend">
110
+ <div class="t">Backend Service β€” Enrich &amp; Score</div>
111
+ <div class="d">GPS β†’ ward / district Β· Severity score Β· Weather risk Β· builds the <b>complete JSON</b></div>
112
+ </div>
113
+ <div class="note right"><b>Why backend</b>Geometry, scoring &amp; weather aren't AI tasks β€” computed here, then handed to Box ready-to-use</div>
114
+ </div>
115
+
116
+ <div class="conn"></div>
117
+ <div class="decision">Duplicate within 50–100&nbsp;m radius?</div>
118
+ <div class="conn sm"></div>
119
+
120
+ <div class="branch">
121
+ <div>
122
+ <div class="lbl tag-yes">YES</div>
123
+ <div class="node backend">
124
+ <div class="t">Link to existing case</div>
125
+ <div class="d">Boost severity Β· no new work order</div>
126
+ </div>
127
+ </div>
128
+ <div>
129
+ <div class="lbl tag-no">NO</div>
130
+ <div class="node backend">
131
+ <div class="t">Create new case</div>
132
+ <div class="d">Fresh case ID</div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+
137
+ <div class="conn"></div>
138
+
139
+ <!-- PHASE 3 -->
140
+ <div class="phase">Phase 3 β€” Box Storage &amp; Routing</div>
141
+ <div class="row">
142
+ <div class="note left"><b>Lands in Box</b>Photo + complete JSON β†’ Incoming Reports folder</div>
143
+ <div class="center node box">
144
+ <div class="t">Upload to Box + Apply Metadata</div>
145
+ <div class="d">Backend writes <b>potholeCase</b> metadata directly β€” works even if Box AI / Extract is off</div>
146
+ </div>
147
+ <div class="note right"><b>Box Extract</b>Confirms / maps JSON β†’ metadata when enabled (optional, not required)</div>
148
+ </div>
149
+
150
+ <div class="conn"></div>
151
+ <div class="row">
152
+ <div class="center node box">
153
+ <div class="t">Case Folder Created + Metadata Applied</div>
154
+ <div class="d">Box Relay Β· Potholes / Ward-XYZ / Case-ABC123</div>
155
+ <span class="status">Under Review</span>
156
+ </div>
157
+ </div>
158
+
159
+ <div class="conn"></div>
160
+
161
+ <!-- PHASE 4 -->
162
+ <div class="phase">Phase 4 β€” Human Review &amp; Dispatch</div>
163
+ <div class="row">
164
+ <div class="center node review">
165
+ <div class="t">Ward Engineer Notified (Box Relay)</div>
166
+ <div class="d">Folder link Β· photo Β· AI summary Β· nearby cases β†’ approve / adjust severity / reject</div>
167
+ </div>
168
+ </div>
169
+ <div class="conn sm"></div>
170
+ <div class="row">
171
+ <div class="center node box">
172
+ <div class="t">Work Order Generated (Box Doc Gen)</div>
173
+ <div class="d">Job Sheet PDF from case metadata β€” no external system needed</div>
174
+ <span class="status">Assigned</span>
175
+ </div>
176
+ </div>
177
+ <div class="conn sm"></div>
178
+ <div class="row">
179
+ <div class="center node crew">
180
+ <div class="t">Crew Completes Box Form (mobile)</div>
181
+ <div class="d">Repair method Β· materials Β· labor hours Β· after-photo Β· <b>Box Sign</b></div>
182
+ <span class="status">In Progress</span>
183
+ </div>
184
+ </div>
185
+
186
+ <div class="conn"></div>
187
+
188
+ <!-- PHASE 5 -->
189
+ <div class="phase">Phase 5 β€” Closeout &amp; Audit Record</div>
190
+ <div class="row">
191
+ <div class="note left"><b>Notifications</b>Supervisor + risk management Β· citizen portal updated to β€œResolved”</div>
192
+ <div class="center node audit">
193
+ <div class="t">Audit-Ready Case Record</div>
194
+ <div class="d">Box Governance β€” retention / legal hold Β· full version history Β· activity log Β· access control</div>
195
+ <span class="status">Resolved</span>
196
+ </div>
197
+ <div class="note right"><b>Citizen view</b>Status checked in the portal β€” no Box login required</div>
198
+ </div>
199
+
200
+ <div class="footer-note">
201
+ <b>Two external dependencies are removed:</b> work orders are generated in Box (Doc Gen), and enrichment runs in our backend β€”
202
+ so the workflow needs <b>no tunnel</b> and <b>no city-system API</b> to demo. The city work-order REST push and live 311 sync remain optional future integrations.
203
+ </div>
204
+ </div>
205
+ </body>
206
+ </html>
box-build/seed-demo-cases.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Seed demo pothole cases into Box for the PotholeIQ dashboard / Command Center.
3
+ * Runs the real backend pipeline (folders + files + potholeCase metadata on the case
4
+ * FOLDER only, incl. severity breakdown), then sets each case's lifecycle status.
5
+ *
6
+ * Usage: node box-build/seed-demo-cases.js <FRESH_DEV_TOKEN> [intakeFolderId]
7
+ */
8
+ 'use strict';
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const bf = require(path.join(__dirname, '..', 'box-folders.js'));
12
+
13
+ const TOKEN = process.argv[2];
14
+ const INTAKE = process.argv[3] || '386202381022';
15
+ if (!TOKEN) { console.error('Pass a fresh Box dev token as arg 1.'); process.exit(1); }
16
+
17
+ const tmpl = { scope: 'enterprise', templateKey: 'potholeCase' };
18
+ const now = () => new Date().toISOString();
19
+ const daysAgo = (d) => new Date(Date.now() - d * 864e5).toISOString();
20
+
21
+ // ---- ward lookup from the bundled Philadelphia GeoJSON (point-in-polygon) ----
22
+ const geo = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'ward-boundaries.geojson'), 'utf8'));
23
+ function pip(pt, poly){let [x,y]=pt,inside=false,n=poly.length,j=n-1;for(let i=0;i<n;i++){const[xi,yi]=poly[i],[xj,yj]=poly[j];if(((yi>y)!=(yj>y))&&(x<(xj-xi)*(y-yi)/(yj-yi)+xi))inside=!inside;j=i;}return inside;}
24
+ 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';}
25
+
26
+ // ---- factor detail text (mirrors enrichment.js) ----
27
+ 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';
28
+ 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';
29
+ 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';
30
+ 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';
31
+ const sizeFromDamage = s => s>=25?'Large':s>=15?'Medium':'Small';
32
+ const riskFromWeather = s => s>=7?'high':s>=4?'medium':'low';
33
+ const levelFromScore = s => s>=76?'Critical':s>=56?'High':s>=31?'Medium':'Low';
34
+ // prior-notice score derived from the duplicate count, so "N prior reports" always == duplicateCount
35
+ const pnFromDup = d => d>=3?30:d>=2?22:d>=1?12:0;
36
+ // real demo photos (so the dashboard shows actual images, not text)
37
+ const DEMO_IMAGES = fs.readdirSync(path.join(__dirname, '..', 'demo pothole images')).filter(f => /\.jpe?g$/i.test(f));
38
+
39
+ // [name, lat, lng, prior, traffic, damage, weather, status, crew, dupCount]
40
+ const RAW = [
41
+ ['City Hall', 39.9526,-75.1652, 22,25,25,10, 'under review','', 2],
42
+ ['Kensington Ave', 39.9905,-75.1190, 30,18,25, 8, 'assigned','Crew Alpha', 3],
43
+ ['Broad & Erie', 40.0090,-75.1490, 22,25,25, 7, 'in progress','Crew Bravo', 0],
44
+ ['Frankford Ave', 39.9690,-75.1300, 22,18,15, 7, 'under review','', 1],
45
+ ['Germantown Ave', 40.0330,-75.1730, 22,18,15, 6, 'assigned','Crew Charlie', 0],
46
+ ['Fairmount Ave', 39.9670,-75.1750, 12,25,15, 7, 'resolved','Crew Alpha', 0],
47
+ ['Frankford NE', 40.0220,-75.0840, 14,18,15,10, 'under review','', 0],
48
+ ['Lancaster Ave', 39.9612,-75.2090, 12,18, 6, 4, 'under review','', 0],
49
+ ['Manayunk Main St', 40.0260,-75.2230, 0,18,15, 4, 'assigned','Crew Delta', 0],
50
+ ['Point Breeze Ave', 39.9320,-75.1830, 12,12, 6, 7, 'resolved','Crew Bravo', 0],
51
+ ['Mount Airy', 40.0560,-75.1880, 0,18,15, 0, 'in progress','Crew Charlie',0],
52
+ ['Old City 2nd St', 39.9510,-75.1430, 12,12, 6, 4, 'under review','', 0],
53
+ ['Roxborough Ridge', 40.0440,-75.2230, 0,12, 6, 4, 'resolved','Crew Delta', 0],
54
+ ['Snyder Ave', 39.9210,-75.1700, 0, 6, 6, 4, 'rejected','', 0],
55
+ ['Bustleton Ave', 40.0600,-75.0500, 0, 6, 6, 0, 'rejected','', 0],
56
+ ];
57
+
58
+ const CASES = RAW.map(([name,lat,lng,pn0,tr,dm,we,status,crew,dup],i)=>{
59
+ const pn = pnFromDup(dup); // keep prior-notice consistent with duplicate count
60
+ const score = pn+tr+dm+we;
61
+ return {
62
+ suffix: String(i+1).padStart(2,'0'), name, lat, lng, status, crew,
63
+ ward: String(wardOf(lat,lng)),
64
+ address: `${name}, Philadelphia, PA`,
65
+ size: sizeFromDamage(dm), score, level: levelFromScore(score),
66
+ dupCount: dup, linked: dup>0 ? [`PHX-LEGACY-${100+i}`,`PHX-LEGACY-${200+i}`].slice(0,dup) : [],
67
+ submittedAt: daysAgo((i%10)+1),
68
+ bd: {
69
+ priorNotice:{score:pn,detail:PN(pn)}, traffic:{score:tr,detail:TR(tr)},
70
+ damage:{score:dm,detail:DM(dm)}, weather:{score:we,detail:WE(we),riskLevel:riskFromWeather(we)},
71
+ },
72
+ };
73
+ });
74
+
75
+ async function uploadFile(name, bytes){
76
+ const fd=new FormData();
77
+ fd.append('attributes',JSON.stringify({name,parent:{id:INTAKE}}));
78
+ fd.append('file',new Blob([bytes]),name);
79
+ 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());
80
+ return r.entries[0].id;
81
+ }
82
+ const md = (id) => fetch(`https://api.box.com/2.0/folders/${id}/metadata/enterprise/potholeCase`,{headers:{Authorization:`Bearer ${TOKEN}`}}).then(r=>r.json());
83
+
84
+ (async () => {
85
+ const me = await fetch('https://api.box.com/2.0/users/me?fields=name,login',{headers:{Authorization:`Bearer ${TOKEN}`}}).then(r=>r.json());
86
+ if(!me.login){console.error('Token invalid:',JSON.stringify(me));process.exit(1);}
87
+ console.log('Auth OK:', me.name);
88
+
89
+ let dist={Critical:0,High:0,Medium:0,Low:0};
90
+ for(const c of CASES){
91
+ const caseId=`PHX-DEMO-${c.suffix}`;
92
+ 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'};
93
+ const enrichment={severityScore:c.score,severityLevel:c.level,enrichedAt:c.submittedAt,
94
+ district:{district:`District ${c.ward}`,ward:c.ward,maintenanceZone:`Zone-${c.ward}`},
95
+ duplicate:{isDuplicate:c.dupCount>0,duplicateCount:c.dupCount,linkedCaseIds:c.linked,effectiveSeverityBoost:c.dupCount*4},
96
+ 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}},
97
+ report};
98
+ const imgBytes=fs.readFileSync(path.join(__dirname,'..','demo pothole images',DEMO_IMAGES[(parseInt(c.suffix,10)-1)%DEMO_IMAGES.length]));
99
+ const photoFileId=await uploadFile(`${caseId}_photo.jpg`,imgBytes);
100
+ const sidecarFileId=await uploadFile(`${caseId}_metadata.json`,JSON.stringify(report,null,2));
101
+ 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:()=>{}});
102
+ // set lifecycle status (organizeInBox defaults to 'under review')
103
+ if(c.status!=='under review'){
104
+ const patch={status:c.status};
105
+ if(c.crew)patch.assignedCrew=c.crew;
106
+ if(c.status==='resolved'){patch.resolvedAt=now();patch.supervisorWardEngineer='Eng. R. Diaz';}
107
+ await bf.applyFolderMetadata(res.caseFolderId,patch,TOKEN,{template:tmpl});
108
+ } else if(c.crew){
109
+ await bf.applyFolderMetadata(res.caseFolderId,{assignedCrew:c.crew},TOKEN,{template:tmpl});
110
+ }
111
+ dist[c.level]++;
112
+ console.log(` ${caseId} Ward ${c.ward.padStart(2)} ${c.level.padEnd(8)} ${String(c.score).padStart(3)} ${c.status}`);
113
+ }
114
+ console.log('\nSeeded',CASES.length,'cases | severity:',JSON.stringify(dist));
115
+ console.log('Open: http://localhost:3030/command-center.html');
116
+ })().catch(e=>{console.error('ERROR',e);process.exit(1);});
box-build/wf1-revised-diagram.html ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Workflow One β€” Revised (Intake β†’ Dispatch)</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: 'Segoe UI', Inter, system-ui, Arial, sans-serif; background: #f4f6fb; color: #1f2733; padding: 36px 16px 64px; line-height: 1.4; }
10
+ .wrap { max-width: 900px; margin: 0 auto; }
11
+ h1 { font-size: 24px; font-weight: 800; text-align: center; color: #0b1f3a; }
12
+ .subtitle { text-align: center; color: #5b6679; font-size: 13.5px; margin-top: 6px; }
13
+ .legend { display: flex; gap: 16px; justify-content: center; margin: 16px 0 26px; flex-wrap: wrap; font-size: 12.5px; color: #44506a; }
14
+ .legend span { display: inline-flex; align-items: center; gap: 7px; }
15
+ .dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
16
+ .node { border-radius: 12px; padding: 12px 16px; text-align: center; border: 1px solid; box-shadow: 0 2px 6px rgba(15,30,60,.06); max-width: 420px; margin: 0 auto; }
17
+ .node .t { font-weight: 700; font-size: 14.5px; }
18
+ .node .d { font-size: 12px; margin-top: 3px; opacity: .92; }
19
+ .trigger { background:#e9f2fd; border-color:#a9cdf6; color:#0a4a9c; }
20
+ .ai { background:#f1ecfe; border-color:#c3b1f5; color:#4c2aa3; }
21
+ .action { background:#e7f7ef; border-color:#9ae0c0; color:#0c6b46; }
22
+ .doc { background:#fff4e6; border-color:#f4c68a; color:#9a5b06; }
23
+ .notify { background:#fdeaf5; border-color:#f3b6da; color:#a01f72; }
24
+ .end { background:#eef2f8; border-color:#ccd6e6; color:#3b475e; font-size:12.5px; }
25
+ .removed { background:#fdeaea; border-color:#f4b4b4; color:#ad2222; text-decoration: line-through; opacity:.8; }
26
+ .decision { background:#fff6e3; border:1px solid #f1c878; color:#92600c; border-radius:12px; padding:11px 16px; text-align:center; font-weight:700; font-size:14px; max-width:420px; margin:0 auto; box-shadow:0 2px 6px rgba(15,30,60,.06); }
27
+ .conn { width:2px; height:22px; background:#b9c3d4; margin:5px auto; }
28
+ .conn.sm { height:14px; }
29
+ .branch { display:grid; grid-template-columns:1fr 1fr; gap:18px; max-width:820px; margin:0 auto; }
30
+ .lbl { text-align:center; font-size:11px; font-weight:700; color:#7a8499; margin-bottom:6px; letter-spacing:.05em; text-transform:uppercase; }
31
+ .col { display:flex; flex-direction:column; gap:0; }
32
+ .note { background:#fff; border:1px dashed #b9c3d4; border-radius:9px; padding:8px 12px; font-size:11.5px; color:#44506a; max-width:520px; margin:6px auto 0; text-align:center; }
33
+ .phase { text-align:center; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#7a8499; margin:22px auto 12px; border-top:1px dashed #c4ccda; padding-top:10px; max-width:600px; }
34
+ .star { color:#9a5b06; font-weight:800; }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div class="wrap">
39
+ <h1>Workflow One β€” Revised</h1>
40
+ <div class="subtitle">Backend owns folders + metadata Β· WF1 = human workflow only Β· <span class="star">+ Work Order added</span></div>
41
+
42
+ <div class="legend">
43
+ <span><i class="dot" style="background:#0a4a9c"></i> Trigger</span>
44
+ <span><i class="dot" style="background:#4c2aa3"></i> Box AI</span>
45
+ <span><i class="dot" style="background:#0c6b46"></i> Metadata / Action</span>
46
+ <span><i class="dot" style="background:#a01f72"></i> Notification</span>
47
+ <span><i class="dot" style="background:#9a5b06"></i> Doc Gen</span>
48
+ <span><i class="dot" style="background:#ad2222"></i> Remove</span>
49
+ </div>
50
+
51
+ <!-- TRIGGER -->
52
+ <div class="node trigger"><div class="t">⚑ Trigger β€” Metadata Event</div><div class="d">potholeCase Β· <b>status = under review</b> &nbsp;(fires after the backend applies metadata β€” loop-safe, no race)</div></div>
53
+ <div class="note">β›” CHANGED from "File Uploaded β†’ Incoming Detections" (that raced the backend & fired before metadata existed)</div>
54
+ <div class="conn"></div>
55
+
56
+ <!-- REMOVED Extract -->
57
+ <div class="node removed"><div class="t">Default Standard Extract Agent</div><div class="d">DELETE β€” backend already wrote the metadata; this node was halting the flow</div></div>
58
+ <div class="conn"></div>
59
+
60
+ <!-- Box Agent -->
61
+ <div class="node ai"><div class="t">πŸ€– Box Agent β€” AI Summary</div><div class="d">2–3 sentence case summary β†’ write to <b>aiSummary</b></div></div>
62
+ <div class="conn"></div>
63
+
64
+ <!-- Decision: duplicate -->
65
+ <div class="decision">Conditional Split β€” duplicateStatus = "duplicate "?</div>
66
+ <div class="conn sm"></div>
67
+
68
+ <div class="branch">
69
+ <div class="col">
70
+ <div class="lbl">Branch 1 β€” DUPLICATE</div>
71
+ <div class="node removed"><div class="t">Move β†’ duplicates/</div><div class="d">DELETE β€” backend already placed the file</div></div>
72
+ <div class="conn sm"></div>
73
+ <div class="node notify"><div class="t">πŸ”” Send Notification</div><div class="d">Alert ward engineer: linked to existing cluster ({linkedCaseIds})</div></div>
74
+ <div class="conn sm"></div>
75
+ <div class="node end">β›” End β€” no work order (it's a duplicate)</div>
76
+ </div>
77
+
78
+ <div class="col">
79
+ <div class="lbl">Else β€” NEW CASE</div>
80
+ <div class="node removed"><div class="t">Create "Case-{caseId}" folder</div><div class="d">DELETE β€” backend creates Potholes/{ward}/Case-{id}</div></div>
81
+ <div class="conn sm"></div>
82
+ <div class="node notify"><div class="t">πŸ”” Send Notification β†’ ward engineer</div><div class="d">New case {caseId} Β· {severityLevel} Β· {aiSummary} + folder link</div></div>
83
+ <div class="conn sm"></div>
84
+ <div class="node action"><div class="t">βœ… Approval Task</div><div class="d">"Approve, adjust severity, or reject" β†’ ward engineer</div></div>
85
+ </div>
86
+ </div>
87
+
88
+ <div class="phase">New case Β· on task result</div>
89
+ <div class="decision">Conditional Split β€” Task Approved?</div>
90
+ <div class="conn sm"></div>
91
+
92
+ <div class="branch">
93
+ <div class="col">
94
+ <div class="lbl">Approved</div>
95
+ <div class="node action"><div class="t">🏷️ Add Metadata</div><div class="d">status = <b>assigned</b></div></div>
96
+ <div class="conn sm"></div>
97
+ <div class="node doc"><div class="t">πŸ“„ Generate Document β€” WORK ORDER β˜…</div><div class="d">Template: <b>Pothole Repair Work Order (DocGen)</b> Β· save PDF to case folder<br>Tags from metadata by <b>caseId</b>: caseId Β· severityLevel Β· address Β· ward Β· assignedCrew</div></div>
98
+ <div class="conn sm"></div>
99
+ <div class="node action"><div class="t">🏷️ Add Metadata</div><div class="d">workOrderId = {generated doc name / id}</div></div>
100
+ <div class="conn sm"></div>
101
+ <div class="node end">β›” End β€” case Assigned, work order filed</div>
102
+ </div>
103
+ <div class="col">
104
+ <div class="lbl">Rejected</div>
105
+ <div class="node action"><div class="t">🏷️ Add Metadata</div><div class="d">status = <b>rejected</b></div></div>
106
+ <div class="conn sm"></div>
107
+ <div class="node notify"><div class="t">πŸ”” Send Notification</div><div class="d">"Case {caseId} ({severityLevel}) was rejected"</div></div>
108
+ <div class="conn sm"></div>
109
+ <div class="node end">β›” End β€” case Rejected</div>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="note" style="margin-top:24px;border-color:#9a5b06;color:#7a4a05;">
114
+ <b>β˜… The work order you flagged:</b> add a <b>Generate Document</b> node in the Approved branch.
115
+ Crew may not be assigned yet at approval, so <b>assignedCrew</b> can be blank here and filled at the deploy-crew step.
116
+ </div>
117
+ </div>
118
+ </body>
119
+ </html>
box-folders.js ADDED
@@ -0,0 +1,656 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * box-folders.js
3
+ * Box folder hierarchy creation, file moving, and metadata writing
4
+ * for the pothole operations enrichment pipeline.
5
+ *
6
+ * Creates structure:
7
+ * [Root Incoming Folder]
8
+ * \- Potholes/
9
+ * \- Ward-29/
10
+ * \- Case-PHX-1234/
11
+ * |- PHX-1234_photo.jpg
12
+ * \- PHX-1234_metadata.json
13
+ *
14
+ * Uses Box generic "properties" metadata for compatibility and can also write
15
+ * to a named Box metadata template when one is configured.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const BOX_API = 'https://api.box.com/2.0';
21
+ const BOX_UPLOAD = 'https://upload.box.com/api/2.0';
22
+ const boxWorkflowModule = (typeof module !== 'undefined' && module.exports)
23
+ ? require('./box-workflow.js')
24
+ : null;
25
+
26
+ async function boxRequest(method, path, token, body = null) {
27
+ const opts = {
28
+ method,
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
32
+ },
33
+ ...(body ? { body: JSON.stringify(body) } : {}),
34
+ };
35
+ const res = await fetch(`${BOX_API}${path}`, opts);
36
+ const data = await res.json().catch(() => ({}));
37
+ return { ok: res.ok, status: res.status, data };
38
+ }
39
+
40
+ function normalizeTemplateConfig(config = {}) {
41
+ return {
42
+ scope: String(config?.scope || '').trim(),
43
+ templateKey: String(config?.templateKey || '').trim(),
44
+ };
45
+ }
46
+
47
+ function shouldApplyCustomTemplate(config = {}) {
48
+ const normalized = normalizeTemplateConfig(config);
49
+ if (!normalized.scope || !normalized.templateKey) return false;
50
+ return !(normalized.scope === 'global' && normalized.templateKey === 'properties');
51
+ }
52
+
53
+ // --- Structured metadata template projection -------------------------------
54
+ // A Box metadata template REJECTS unknown keys, wrong value types (float/date),
55
+ // and enum values that don't exactly match a stored option key. The backend's
56
+ // rich review object can't be sent as-is, so we project it onto the live schema:
57
+ // pick the right source value per field, coerce its type, and snap enum values
58
+ // to the exact option key the template stores (case/whitespace insensitive).
59
+
60
+ const __templateSchemaCache = new Map();
61
+
62
+ async function getTemplateSchema(scope, templateKey, token) {
63
+ const cacheKey = `${scope}/${templateKey}`;
64
+ if (__templateSchemaCache.has(cacheKey)) return __templateSchemaCache.get(cacheKey);
65
+ const { ok, data } = await boxRequest('GET', `/metadata_templates/${scope}/${templateKey}/schema`, token);
66
+ const schema = ok && Array.isArray(data.fields) ? data : null;
67
+ __templateSchemaCache.set(cacheKey, schema);
68
+ return schema;
69
+ }
70
+
71
+ // template field key -> ordered source keys in the review kvMap (first non-empty wins)
72
+ const TEMPLATE_KEY_ALIASES = {
73
+ severityScore0100: ['severityScore0100', 'severityScore'],
74
+ weatherRisk: ['weatherRisk', 'weatherStatus', 'weatherRiskLevel'],
75
+ supervisorWardEngineer: ['supervisorWardEngineer', 'supervisor'],
76
+ caseFolderId: ['caseFolderId', 'boxCaseFolderId', 'boxFolderId'],
77
+ linkedCaseIds: ['linkedCaseIds', 'linkedCases'],
78
+ status: ['status', 'workflowStatus'],
79
+ };
80
+
81
+ // Per-field input-value remaps applied BEFORE matching against the schema options.
82
+ // Needed where the source vocabulary differs from the template's option set.
83
+ const ENUM_VALUE_ALIASES = {
84
+ weatherRisk: { high: 'large', severe: 'large' }, // template has no "High" option (Low/Medium/Large)
85
+ };
86
+
87
+ function pickSourceValue(kvMap, templateKey) {
88
+ const aliases = TEMPLATE_KEY_ALIASES[templateKey] || [templateKey];
89
+ for (const alias of aliases) {
90
+ const v = kvMap[alias];
91
+ if (v !== undefined && v !== null && String(v).trim() !== '') return v;
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function matchEnumOption(value, options = []) {
97
+ const norm = (s) => String(s).trim().toLowerCase();
98
+ const target = norm(value);
99
+ const opt = options.find((o) => norm(o.key) === target);
100
+ return opt ? opt.key : null; // return EXACT stored key (preserves casing/trailing spaces)
101
+ }
102
+
103
+ function coerceForField(field, rawValue) {
104
+ if (rawValue === undefined || rawValue === null) return undefined;
105
+ const sval = String(rawValue).trim();
106
+ if (sval === '') return undefined;
107
+ switch (field.type) {
108
+ case 'float': {
109
+ const n = Number(sval);
110
+ return Number.isFinite(n) ? n : undefined;
111
+ }
112
+ case 'date': {
113
+ const d = new Date(sval);
114
+ if (Number.isNaN(d.getTime())) return undefined;
115
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); // RFC3339, no milliseconds
116
+ }
117
+ case 'enum':
118
+ case 'multiSelect': {
119
+ const valAlias = (ENUM_VALUE_ALIASES[field.key] || {})[sval.toLowerCase()];
120
+ return matchEnumOption(valAlias || sval, field.options) || undefined;
121
+ }
122
+ default:
123
+ return sval;
124
+ }
125
+ }
126
+
127
+ function projectToTemplateSchema(kvMap, schema) {
128
+ const out = {};
129
+ for (const field of schema.fields || []) {
130
+ const coerced = coerceForField(field, pickSourceValue(kvMap, field.key));
131
+ if (coerced !== undefined) out[field.key] = coerced;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ async function getOrCreateFolder(parentId, name, token) {
137
+ console.log(`[BoxFolders] getOrCreateFolder("${name}") in parent ${parentId}`);
138
+ const { ok, status, data } = await boxRequest('POST', '/folders', token, {
139
+ name,
140
+ parent: { id: parentId },
141
+ });
142
+
143
+ if (ok) {
144
+ console.log(`[BoxFolders] Folder created: "${name}" -> id=${data.id}`);
145
+ return data.id;
146
+ }
147
+
148
+ if (status === 409) {
149
+ const existingId = data.context_info?.conflicts?.[0]?.id || null;
150
+ if (existingId) {
151
+ console.log(`[BoxFolders] Folder already exists: "${name}" -> id=${existingId}`);
152
+ return existingId;
153
+ }
154
+
155
+ const items = await boxRequest('GET', `/folders/${parentId}/items?limit=200`, token);
156
+ const match = (items.data?.entries || []).find((entry) => entry.type === 'folder' && entry.name === name);
157
+ if (match) {
158
+ console.log(`[BoxFolders] Folder found via listing: "${name}" -> id=${match.id}`);
159
+ return match.id;
160
+ }
161
+
162
+ throw new Error(`Could not resolve existing folder "${name}" in parent ${parentId}`);
163
+ }
164
+
165
+ throw new Error(`Create folder "${name}" failed (${status}): ${data.message || JSON.stringify(data)}`);
166
+ }
167
+
168
+ async function moveFile(fileId, destFolderId, token) {
169
+ console.log(`[BoxFolders] moveFile fileId=${fileId} -> folder ${destFolderId}`);
170
+ const { ok, status, data } = await boxRequest('PUT', `/files/${fileId}`, token, {
171
+ parent: { id: destFolderId },
172
+ });
173
+ if (!ok) throw new Error(`Move file ${fileId} failed (${status}): ${data.message || ''}`);
174
+ console.log(
175
+ `[BoxFolders] File moved. New path: ${(data.path_collection?.entries || []).map((entry) => entry.name).join('/')}`
176
+ );
177
+ return data;
178
+ }
179
+
180
+ async function uploadGeneratedFile(caseFolderId, fileName, contents, mimeType, token) {
181
+ const blob = new Blob([contents], { type: mimeType });
182
+ const attributes = JSON.stringify({ name: fileName, parent: { id: caseFolderId } });
183
+
184
+ const form = new FormData();
185
+ form.append('attributes', attributes);
186
+ form.append('file', blob, fileName);
187
+
188
+ const res = await fetch(`${BOX_UPLOAD}/files/content`, {
189
+ method: 'POST',
190
+ headers: { Authorization: `Bearer ${token}` },
191
+ body: form,
192
+ });
193
+
194
+ if (!res.ok) {
195
+ const err = await res.json().catch(() => ({}));
196
+ throw new Error(`Generated file upload failed (${res.status}): ${err.message || ''}`);
197
+ }
198
+
199
+ const data = await res.json();
200
+ console.log(`[BoxFolders] Generated file uploaded: ${fileName} -> id=${data.entries?.[0]?.id}`);
201
+ return data.entries?.[0]?.id || null;
202
+ }
203
+
204
+ async function uploadEnrichedSidecar(caseFolderId, caseId, enrichedPayload, token) {
205
+ const fileName = `${caseId}_enriched_metadata.json`;
206
+ const contents = JSON.stringify(enrichedPayload, null, 2);
207
+ return uploadGeneratedFile(caseFolderId, fileName, contents, 'application/json', token);
208
+ }
209
+
210
+ function buildReviewSummary(caseId, reportPayload, enrichment, reviewMetadata, folderPath) {
211
+ const address = reportPayload?.address || 'Address not available';
212
+ const district = reviewMetadata.district || 'Unknown';
213
+ const ward = reviewMetadata.ward || 'Unknown';
214
+ const zone = reviewMetadata.maintenanceZone || 'Unknown';
215
+ const severity = `${reviewMetadata.severityLevel || 'Unknown'} (${reviewMetadata.severityScore || 'n/a'}/100)`;
216
+ const road = reviewMetadata.trafficDetail || 'Road classification unavailable';
217
+ const weather = reviewMetadata.weatherDetail || 'Weather risk unavailable';
218
+ const weatherStatus = reviewMetadata.weatherStatus || reviewMetadata.weatherRiskLevel || 'Unknown';
219
+ const potholeSize = reviewMetadata.potholeSize || reviewMetadata.aiResult || reviewMetadata.aiPrediction || 'Unknown';
220
+ const aiConfidence = reviewMetadata.aiConfidence !== ''
221
+ ? `${reviewMetadata.aiConfidence}%`
222
+ : 'Unavailable';
223
+ const visualVerification = reviewMetadata.needsVisualVerification === 'true'
224
+ ? 'Yes - AI was not confident this is a pothole. Send image for Box-side visual verification.'
225
+ : 'No - base model confidence was sufficient.';
226
+ const duplicate = reviewMetadata.isDuplicate === 'true'
227
+ ? `Yes (${reviewMetadata.duplicateCount || 0} related case(s), linked: ${reviewMetadata.linkedCases || 'none'})`
228
+ : 'No';
229
+ const priorNotice = reviewMetadata.priorNoticeDetail || 'No prior notice detail';
230
+ const damage = reviewMetadata.damageDetail || 'Damage detail unavailable';
231
+ const reporterNote = reportPayload?.description || reportPayload?.notes || 'No additional citizen notes.';
232
+
233
+ return [
234
+ `POTHOLE REVIEW SUMMARY`,
235
+ ``,
236
+ `Case ID: ${caseId}`,
237
+ `Address: ${address}`,
238
+ `Ward: ${ward}`,
239
+ `District: ${district}`,
240
+ `Maintenance Zone: ${zone}`,
241
+ `Box Path: ${folderPath}`,
242
+ ``,
243
+ `SUPERVISOR REVIEW DATA`,
244
+ `Severity: ${severity}`,
245
+ `Pothole Size: ${potholeSize}`,
246
+ `Road Context: ${road}`,
247
+ `Road Classification: ${reviewMetadata.roadClassification || 'unknown'}`,
248
+ `Weather Risk: ${weather}`,
249
+ `Weather Status: ${weatherStatus}`,
250
+ `AI Confidence: ${aiConfidence}`,
251
+ `Needs Visual Verification: ${visualVerification}`,
252
+ `Duplicate Cluster: ${duplicate}`,
253
+ `Prior Notice: ${priorNotice}`,
254
+ `Damage Assessment: ${damage}`,
255
+ ``,
256
+ `REPORTER NOTES`,
257
+ `${reporterNote}`,
258
+ ``,
259
+ `REVIEW INSTRUCTION`,
260
+ `Check the photo, summary, severity, duplicate context, ward/district/zone, and decide the next operational step.`,
261
+ ``,
262
+ `Generated: ${reviewMetadata.enrichedAt || new Date().toISOString()}`,
263
+ ].join('\n');
264
+ }
265
+
266
+ async function uploadReviewSummary(caseFolderId, caseId, reviewSummary, token, options = {}) {
267
+ const fileName = options.needsVisualVerification
268
+ ? `${caseId}_review_summary_uncertain.txt`
269
+ : `${caseId}_review_summary.txt`;
270
+ return uploadGeneratedFile(caseFolderId, fileName, reviewSummary, 'text/plain', token);
271
+ }
272
+
273
+ function buildFolderWorkflowMetadata(reportPayload, enrichment, context) {
274
+ if (typeof window !== 'undefined' && window.BoxWorkflow?.buildEnrichedWorkflow) {
275
+ return window.BoxWorkflow.buildEnrichedWorkflow(reportPayload, enrichment, context);
276
+ }
277
+ if (boxWorkflowModule?.buildEnrichedWorkflow) {
278
+ return boxWorkflowModule.buildEnrichedWorkflow(reportPayload, enrichment, context);
279
+ }
280
+
281
+ return {
282
+ template: 'pothole_box_ops_v1',
283
+ currentStage: 'supervisor_review',
284
+ currentStageLabel: 'Supervisor Review',
285
+ status: 'Under Review',
286
+ queue: 'Supervisor Review',
287
+ nextAction: 'Supervisor approve the case and send it to crew routing',
288
+ priority: 'normal',
289
+ boxPath: context.folderPath || '',
290
+ lastUpdatedAt: enrichment?.enrichedAt || new Date().toISOString(),
291
+ };
292
+ }
293
+
294
+ function flattenFolderWorkflow(workflow) {
295
+ if (typeof window !== 'undefined' && window.BoxWorkflow?.flattenWorkflowForBox) {
296
+ return window.BoxWorkflow.flattenWorkflowForBox(workflow);
297
+ }
298
+ if (boxWorkflowModule?.flattenWorkflowForBox) {
299
+ return boxWorkflowModule.flattenWorkflowForBox(workflow);
300
+ }
301
+
302
+ return {
303
+ workflowTemplate: workflow.template || 'pothole_box_ops_v1',
304
+ workflowStage: workflow.currentStage || '',
305
+ workflowStageLabel: workflow.currentStageLabel || '',
306
+ workflowStatus: workflow.status || '',
307
+ workflowQueue: workflow.queue || '',
308
+ workflowNextAction: workflow.nextAction || '',
309
+ workflowPriority: workflow.priority || '',
310
+ workflowBoxPath: workflow.boxPath || '',
311
+ workflowLastUpdatedAt: workflow.lastUpdatedAt || '',
312
+ };
313
+ }
314
+
315
+ async function writeMetadataDocument(itemType, itemId, kvMap, token, config = {}, options = {}) {
316
+ const { stringifyValues = true } = options;
317
+ const normalized = normalizeTemplateConfig(config);
318
+ const scope = normalized.scope || 'global';
319
+ const templateKey = normalized.templateKey || 'properties';
320
+ const path = `/${itemType}/${itemId}/metadata/${scope}/${templateKey}`;
321
+ const payload = stringifyValues
322
+ ? Object.fromEntries(Object.entries(kvMap).map(([key, value]) => [key, String(value ?? '')]))
323
+ : { ...kvMap };
324
+ if (!Object.keys(payload).length) {
325
+ console.log(`[BoxFolders] No template-valid fields to write for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
326
+ return;
327
+ }
328
+
329
+ const { ok, status } = await boxRequest('POST', path, token, payload);
330
+ if (ok) {
331
+ console.log(`[BoxFolders] Metadata applied to ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
332
+ return;
333
+ }
334
+
335
+ if (status === 409) {
336
+ const ops = Object.entries(payload).map(([key, value]) => ({ op: 'add', path: `/${key}`, value }));
337
+ const patchRes = await fetch(`${BOX_API}${path}`, {
338
+ method: 'PUT',
339
+ headers: {
340
+ Authorization: `Bearer ${token}`,
341
+ 'Content-Type': 'application/json-patch+json',
342
+ },
343
+ body: JSON.stringify(ops),
344
+ });
345
+ if (!patchRes.ok) {
346
+ const patchErr = await patchRes.json().catch(() => ({}));
347
+ throw new Error(`Metadata patch failed (${patchRes.status}): ${patchErr.message || ''}`.trim());
348
+ }
349
+ console.log(`[BoxFolders] Metadata patched (${patchRes.status}) for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
350
+ return;
351
+ }
352
+
353
+ throw new Error(`Metadata write failed (${status}) for ${itemType.slice(0, -1)} ${itemId} (${scope}/${templateKey})`);
354
+ }
355
+
356
+ async function applyMetadata(itemType, itemId, kvMap, token, options = {}) {
357
+ // Generic freeform properties: accepts any key, values as strings.
358
+ await writeMetadataDocument(itemType, itemId, kvMap, token, { scope: 'global', templateKey: 'properties' });
359
+
360
+ if (shouldApplyCustomTemplate(options.template)) {
361
+ const { scope, templateKey } = normalizeTemplateConfig(options.template);
362
+ const schema = await getTemplateSchema(scope, templateKey, token);
363
+ if (!schema) {
364
+ console.warn(`[BoxFolders] Schema unavailable for ${scope}/${templateKey}; skipping structured template.`);
365
+ return;
366
+ }
367
+ const projected = projectToTemplateSchema(kvMap, schema);
368
+ await writeMetadataDocument(itemType, itemId, projected, token, options.template, { stringifyValues: false });
369
+ }
370
+ }
371
+
372
+ async function applyFolderMetadata(folderId, kvMap, token, options = {}) {
373
+ return applyMetadata('folders', folderId, kvMap, token, options);
374
+ }
375
+
376
+ async function applyFileMetadata(fileId, kvMap, token, options = {}) {
377
+ return applyMetadata('files', fileId, kvMap, token, options);
378
+ }
379
+
380
+ function classifyRoadContext(detail = '') {
381
+ const normalized = String(detail || '').toLowerCase();
382
+ if (normalized.includes('critical infrastructure') || normalized.includes('high-traffic')) return 'critical';
383
+ if (normalized.includes('major arterial') || normalized.includes('high traffic')) return 'high';
384
+ if (normalized.includes('residential') || normalized.includes('low-traffic')) return 'low';
385
+ if (normalized.includes('moderate')) return 'medium';
386
+ return 'unknown';
387
+ }
388
+
389
+ function isUncertainPotholeCase(reportPayload = {}) {
390
+ const reviewStatus = String(reportPayload?.aiReviewStatus || '').toLowerCase().trim();
391
+ const aiResult = String(reportPayload?.aiResult || '').trim();
392
+ return reviewStatus === 'manual_review' && !aiResult;
393
+ }
394
+
395
+ function buildReviewMetadata(caseId, ward, district, folderPath, enrichment, workflowFields) {
396
+ const breakdown = enrichment?.severityBreakdown || {};
397
+ const priorNotice = breakdown.priorNotice || {};
398
+ const traffic = breakdown.traffic || {};
399
+ const damage = breakdown.damage || {};
400
+ const weather = breakdown.weather || {};
401
+ const duplicate = enrichment?.duplicate || {};
402
+ const districtData = enrichment?.district || {};
403
+ const reportPayload = enrichment?.report || {};
404
+ const aiReviewStatus = String(reportPayload.aiReviewStatus || '').trim();
405
+ const aiResult = String(reportPayload.aiResult || '').trim();
406
+ const aiPrediction = String(reportPayload.aiPrediction || '').trim();
407
+ const aiConfidenceRaw = reportPayload.aiConfidence;
408
+ const aiConfidence = Number.isFinite(Number(aiConfidenceRaw))
409
+ ? Math.round(Number(aiConfidenceRaw) * 100)
410
+ : '';
411
+ const needsVisualVerification = isUncertainPotholeCase(reportPayload);
412
+ const duplicateStatus = duplicate.isDuplicate ? 'duplicate' : 'new';
413
+ const weatherStatus = weather.riskLevel || (weather.detail ? 'available' : 'pending');
414
+ const potholeSize = aiResult || aiPrediction || String(reportPayload.potholeSize || '').trim();
415
+
416
+ return {
417
+ caseId,
418
+ submittedAt: reportPayload.submittedAt || '',
419
+ address: reportPayload.address || '',
420
+ areaLabel: reportPayload.areaLabel || '',
421
+ latitude: reportPayload.location?.lat ?? '',
422
+ longitude: reportPayload.location?.lng ?? '',
423
+ locationSource: reportPayload.location?.source || reportPayload.locationSource || '',
424
+ district: districtData.district || district || 'Unknown',
425
+ ward: districtData.ward || ward || 'Unknown',
426
+ maintenanceZone: districtData.maintenanceZone || 'Zone-X',
427
+ duplicateStatus,
428
+ severityScore: enrichment?.severityScore ?? '',
429
+ severityLevel: enrichment?.severityLevel || '',
430
+ priorNoticeScore: priorNotice.score ?? '',
431
+ priorNoticeDetail: priorNotice.detail || '',
432
+ trafficScore: traffic.score ?? '',
433
+ trafficDetail: traffic.detail || '',
434
+ roadClassification: classifyRoadContext(traffic.detail),
435
+ damageScore: damage.score ?? '',
436
+ damageDetail: damage.detail || '',
437
+ weatherScore: weather.score ?? '',
438
+ weatherDetail: weather.detail || '',
439
+ weatherRiskLevel: weather.riskLevel || '',
440
+ weatherStatus,
441
+ potholeSize,
442
+ aiResult,
443
+ aiPrediction,
444
+ aiReviewStatus,
445
+ aiReviewMessage: reportPayload.aiReviewMessage || '',
446
+ aiConfidence,
447
+ needsVisualVerification: needsVisualVerification ? 'true' : 'false',
448
+ duplicateCount: duplicate.duplicateCount ?? '',
449
+ duplicateBoost: duplicate.effectiveSeverityBoost ?? '',
450
+ isDuplicate: duplicate.isDuplicate ? 'true' : 'false',
451
+ linkedCases: (duplicate.linkedCaseIds || []).join(', '),
452
+ boxPath: folderPath,
453
+ enrichedAt: enrichment?.enrichedAt || '',
454
+ enrichedBy: 'ops-console-v2',
455
+ workflowStage: 'supervisor_review',
456
+ workflowStatus: 'Under Review',
457
+ workflowNextAction: 'Supervisor review pending',
458
+ workflowPriority: enrichment?.severityLevel === 'Critical' ? 'urgent'
459
+ : enrichment?.severityLevel === 'High' ? 'high'
460
+ : enrichment?.severityLevel === 'Medium' ? 'medium'
461
+ : 'normal',
462
+ ...workflowFields,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * @param {object} opts
468
+ * @param {string} opts.caseId
469
+ * @param {string} opts.ward
470
+ * @param {string} opts.district
471
+ * @param {string} [opts.photoFileId]
472
+ * @param {string} [opts.sidecarFileId]
473
+ * @param {object} opts.enrichment
474
+ * @param {object} opts.reportPayload
475
+ * @param {string} opts.rootFolderId
476
+ * @param {string} opts.token
477
+ * @param {(label: string, status: string) => void} [opts.onStep]
478
+ * @returns {Promise<{ folderPath: string, caseFolderId: string, potholesId: string, wardId: string, enrichedFileId: string|null, reviewSummaryFileId: string|null }>}
479
+ */
480
+ async function organizeInBox(opts = {}) {
481
+ if (typeof window !== 'undefined') {
482
+ const response = await fetch('/api/box/organize-case', {
483
+ method: 'POST',
484
+ headers: {
485
+ 'Content-Type': 'application/json',
486
+ },
487
+ body: JSON.stringify(opts),
488
+ });
489
+
490
+ const payload = await response.json().catch(() => null);
491
+ if (!response.ok) {
492
+ throw new Error(payload?.error || `Box organize failed with HTTP ${response.status}`);
493
+ }
494
+
495
+ return payload?.result || null;
496
+ }
497
+
498
+ const {
499
+ caseId,
500
+ ward,
501
+ district,
502
+ photoFileId,
503
+ sidecarFileId,
504
+ enrichment,
505
+ reportPayload,
506
+ rootFolderId,
507
+ metadataTemplate = {},
508
+ token,
509
+ onStep = () => {},
510
+ } = opts;
511
+ const step = (label, status) => onStep(label, status);
512
+ const metadataOptions = { template: normalizeTemplateConfig(metadataTemplate) };
513
+
514
+ step('Creating Potholes/ root folder...', 'running');
515
+ const potholesId = await getOrCreateFolder(rootFolderId, 'Potholes', token);
516
+ step(`Potholes/ folder ready (id: ${potholesId})`, 'done');
517
+
518
+ const wardSlug = (ward || 'Ward-Unknown').replace(/\s+/g, '-');
519
+ step(`Creating ${wardSlug}/ subfolder...`, 'running');
520
+ const wardId = await getOrCreateFolder(potholesId, wardSlug, token);
521
+ step(`${wardSlug}/ ready (id: ${wardId})`, 'done');
522
+
523
+ const caseFolderName = `Case-${caseId}`;
524
+ step(`Creating case folder ${caseFolderName}/...`, 'running');
525
+ const caseFolderId = await getOrCreateFolder(wardId, caseFolderName, token);
526
+ step(`Case folder created (id: ${caseFolderId})`, 'done');
527
+
528
+ const folderPath = `Potholes/${wardSlug}/${caseFolderName}`;
529
+
530
+ if (photoFileId) {
531
+ step('Moving photo into case folder...', 'running');
532
+ await moveFile(photoFileId, caseFolderId, token).catch((error) => {
533
+ step(`Photo move warning: ${error.message}`, 'warn');
534
+ });
535
+ step('Photo moved OK', 'done');
536
+ }
537
+
538
+ if (sidecarFileId) {
539
+ step('Moving original metadata JSON...', 'running');
540
+ await moveFile(sidecarFileId, caseFolderId, token).catch((error) => {
541
+ step(`Sidecar move warning: ${error.message}`, 'warn');
542
+ });
543
+ step('Metadata JSON moved OK', 'done');
544
+ }
545
+
546
+ const workflow = buildFolderWorkflowMetadata(reportPayload, enrichment, { folderPath, rootFolderId });
547
+ const workflowFields = flattenFolderWorkflow(workflow);
548
+ const reviewMetadata = buildReviewMetadata(caseId, ward, district, folderPath, enrichment, workflowFields);
549
+ const reviewSummary = buildReviewSummary(caseId, reportPayload, enrichment, reviewMetadata, folderPath);
550
+ const needsVisualVerification = isUncertainPotholeCase(reportPayload);
551
+
552
+ step('Uploading enriched metadata JSON...', 'running');
553
+ const enrichedPayload = {
554
+ caseId,
555
+ submittedAt: reportPayload.submittedAt || '',
556
+ enrichedAt: enrichment.enrichedAt,
557
+ address: reportPayload.address || '',
558
+ areaLabel: reportPayload.areaLabel || '',
559
+ location: reportPayload.location || null,
560
+ locationSource: reportPayload.location?.source || reportPayload.locationSource || '',
561
+ severityScore: enrichment.severityScore,
562
+ severityLevel: enrichment.severityLevel,
563
+ district: enrichment.district?.district || district,
564
+ ward: enrichment.district?.ward || ward,
565
+ maintenanceZone: enrichment.district?.maintenanceZone || 'Zone-X',
566
+ duplicateStatus: enrichment.duplicate?.isDuplicate ? 'duplicate' : 'new',
567
+ isDuplicate: enrichment.duplicate?.isDuplicate,
568
+ duplicateCount: enrichment.duplicate?.duplicateCount ?? 0,
569
+ duplicateBoost: enrichment.duplicate?.effectiveSeverityBoost ?? 0,
570
+ linkedCases: enrichment.duplicate?.linkedCaseIds?.join(', ') || '',
571
+ weatherStatus: enrichment.severityBreakdown?.weather?.riskLevel || 'pending',
572
+ potholeSize: reportPayload.aiResult || reportPayload.aiPrediction || '',
573
+ aiResult: reportPayload.aiResult || '',
574
+ aiPrediction: reportPayload.aiPrediction || '',
575
+ aiReviewStatus: reportPayload.aiReviewStatus || '',
576
+ aiReviewMessage: reportPayload.aiReviewMessage || '',
577
+ priorNoticeScore: enrichment.severityBreakdown?.priorNotice?.score ?? '',
578
+ priorNoticeDetail: enrichment.severityBreakdown?.priorNotice?.detail || '',
579
+ trafficScore: enrichment.severityBreakdown?.traffic?.score ?? '',
580
+ trafficDetail: enrichment.severityBreakdown?.traffic?.detail || '',
581
+ roadClassification: classifyRoadContext(enrichment.severityBreakdown?.traffic?.detail),
582
+ damageScore: enrichment.severityBreakdown?.damage?.score ?? '',
583
+ damageDetail: enrichment.severityBreakdown?.damage?.detail || '',
584
+ weatherScore: enrichment.severityBreakdown?.weather?.score ?? '',
585
+ weatherDetail: enrichment.severityBreakdown?.weather?.detail || '',
586
+ weatherRiskLevel: enrichment.severityBreakdown?.weather?.riskLevel || '',
587
+ weatherRisk: enrichment.weather
588
+ ? `${enrichment.severityBreakdown?.weather?.riskLevel || 'unknown'} - ${enrichment.severityBreakdown?.weather?.detail}`
589
+ : 'Unavailable',
590
+ boxPath: folderPath,
591
+ workflow,
592
+ ...workflowFields,
593
+ severityBreakdown: enrichment.severityBreakdown,
594
+ report: reportPayload,
595
+ };
596
+ const enrichedFileId = await uploadEnrichedSidecar(caseFolderId, caseId, enrichedPayload, token).catch((error) => {
597
+ step(`Enriched sidecar warning: ${error.message}`, 'warn');
598
+ return null;
599
+ });
600
+ step('Enriched metadata JSON uploaded OK', 'done');
601
+
602
+ step('Uploading human-readable review summary...', 'running');
603
+ const reviewSummaryFileId = await uploadReviewSummary(caseFolderId, caseId, reviewSummary, token, {
604
+ needsVisualVerification,
605
+ }).catch((error) => {
606
+ step(`Review summary warning: ${error.message}`, 'warn');
607
+ return null;
608
+ });
609
+ step('Review summary uploaded OK', 'done');
610
+
611
+ step('Applying Box folder metadata template...', 'running');
612
+ await applyFolderMetadata(caseFolderId, reviewMetadata, token, metadataOptions).catch((error) => {
613
+ step(`Metadata warning: ${error.message}`, 'warn');
614
+ });
615
+ step('Box metadata template applied OK', 'done');
616
+
617
+ // NOTE: only the case FOLDER carries the structured potholeCase template (it's the
618
+ // canonical case record). Helper files get freeform properties only ({} = no custom
619
+ // template) so the App's metadata view counts 1 record per case, not 3.
620
+ if (enrichedFileId) {
621
+ step('Applying Box metadata to enriched review file...', 'running');
622
+ await applyFileMetadata(enrichedFileId, reviewMetadata, token, {}).catch((error) => {
623
+ step(`Review file metadata warning: ${error.message}`, 'warn');
624
+ });
625
+ step('Review file metadata applied OK', 'done');
626
+ }
627
+
628
+ if (reviewSummaryFileId) {
629
+ step('Applying Box metadata to review summary file...', 'running');
630
+ await applyFileMetadata(reviewSummaryFileId, reviewMetadata, token, {}).catch((error) => {
631
+ step(`Review summary metadata warning: ${error.message}`, 'warn');
632
+ });
633
+ step('Review summary metadata applied OK', 'done');
634
+ }
635
+
636
+ console.log(`[BoxFolders] organizeInBox complete -> ${folderPath}`);
637
+ return { folderPath, caseFolderId, wardId, potholesId, enrichedFileId, reviewSummaryFileId };
638
+ }
639
+
640
+ if (typeof window !== 'undefined') {
641
+ window.BoxFolders = { organizeInBox, getOrCreateFolder, moveFile, applyFolderMetadata };
642
+ }
643
+
644
+ if (typeof module !== 'undefined' && module.exports) {
645
+ module.exports = {
646
+ organizeInBox,
647
+ getOrCreateFolder,
648
+ moveFile,
649
+ applyFolderMetadata,
650
+ applyFileMetadata,
651
+ getTemplateSchema,
652
+ projectToTemplateSchema,
653
+ coerceForField,
654
+ matchEnumOption,
655
+ };
656
+ }
box-service.js ADDED
@@ -0,0 +1,1318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const {
4
+ buildInitialWorkflow,
5
+ flattenWorkflowForBox,
6
+ } = require('./box-workflow.js');
7
+ const {
8
+ organizeInBox,
9
+ applyFolderMetadata,
10
+ getOrCreateFolder,
11
+ } = require('./box-folders.js');
12
+
13
+ const BOX_API_BASE = 'https://api.box.com/2.0';
14
+ const BOX_UPLOAD_URL = 'https://upload.box.com/api/2.0/files/content';
15
+ const BOX_OAUTH_TOKEN_URL = 'https://api.box.com/oauth2/token';
16
+ const DEFAULT_GIS_BOUNDARY_URL = 'https://opendata.arcgis.com/datasets/philly-political-wards.geojson';
17
+ const TOKEN_REFRESH_SKEW_MS = 2 * 60 * 1000;
18
+
19
+ function nowIso() {
20
+ return new Date().toISOString();
21
+ }
22
+
23
+ function normalizeString(value) {
24
+ return String(value ?? '').trim();
25
+ }
26
+
27
+ // The citizen contact field is free-text ("email or phone" and may include a name),
28
+ // so pull the email out of whatever string (or JSON blob) we're handed. Phone-only
29
+ // values yield '' and never trigger a notify.
30
+ function extractEmail(value) {
31
+ const match = String(value || '').match(/[^\s@<>"'(),;:]+@[^\s@<>"'(),;:]+\.[^\s@<>"'(),;:]+/);
32
+ return match ? match[0].replace(/[.,;:>)\]]+$/, '') : '';
33
+ }
34
+
35
+ // Friendly, citizen-facing copy for each status β€” the body of the Box status note.
36
+ const CITIZEN_STATUS_COPY = {
37
+ 'under review': 'Your report is being reviewed by our team. We will update you once a decision is made.',
38
+ submitted: 'Thanks β€” your report has been received and logged. We will review it shortly.',
39
+ assigned: 'Good news! Your report has been approved and a repair crew has been assigned. A work order has been created.',
40
+ 'in progress': 'A crew is now actively working on the repair at the reported location.',
41
+ resolved: 'The pothole has been repaired and your case is now closed. Thank you for helping keep our roads safe.',
42
+ rejected: 'After review, this report was closed without a repair being dispatched. See the notes in your case for details.',
43
+ };
44
+
45
+ function buildCitizenStatusNote(caseId, statusLabel, caseRecord = null) {
46
+ const report = caseRecord?.report || {};
47
+ const key = String(statusLabel || '').trim().toLowerCase();
48
+ const message = CITIZEN_STATUS_COPY[key] || `Your report status has been updated to "${statusLabel}".`;
49
+ const lines = [
50
+ `Pothole Report β€” Status Update`,
51
+ ``,
52
+ `Case Reference: ${caseId}`,
53
+ `Current Status: ${statusLabel}`,
54
+ report.address ? `Location: ${report.address}` : '',
55
+ `Updated: ${new Date().toLocaleString()}`,
56
+ ``,
57
+ message,
58
+ ``,
59
+ `You can view live progress any time on the Track Your Report page using your Case Reference number.`,
60
+ ];
61
+ return lines.filter((line) => line !== '').join('\n');
62
+ }
63
+
64
+ function delay(ms) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+
68
+ function parseRetryAfter(headers) {
69
+ const raw = headers?.get?.('retry-after');
70
+ if (!raw) return null;
71
+
72
+ const seconds = Number(raw);
73
+ if (Number.isFinite(seconds) && seconds >= 0) {
74
+ return seconds * 1000;
75
+ }
76
+
77
+ const when = Date.parse(raw);
78
+ if (Number.isFinite(when)) {
79
+ return Math.max(0, when - Date.now());
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ async function readErrorMessage(response) {
86
+ const contentType = response.headers.get('content-type') || '';
87
+ try {
88
+ if (contentType.includes('application/json')) {
89
+ const payload = await response.json();
90
+ return payload?.message || payload?.error || JSON.stringify(payload);
91
+ }
92
+
93
+ const text = await response.text();
94
+ return text || `HTTP ${response.status}`;
95
+ } catch {
96
+ return `HTTP ${response.status}`;
97
+ }
98
+ }
99
+
100
+ async function fetchWithRetry(url, options = {}, retryOptions = {}) {
101
+ const {
102
+ retries = 3,
103
+ baseDelayMs = 600,
104
+ maxDelayMs = 5000,
105
+ label = 'request',
106
+ retryStatuses = [408, 429, 500, 502, 503, 504],
107
+ } = retryOptions;
108
+
109
+ let lastError = null;
110
+
111
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
112
+ try {
113
+ const response = await fetch(url, options);
114
+ if (!retryStatuses.includes(response.status) || attempt === retries) {
115
+ return response;
116
+ }
117
+
118
+ const retryDelay = parseRetryAfter(response.headers)
119
+ ?? Math.min(maxDelayMs, baseDelayMs * (2 ** attempt));
120
+ console.warn(`[retry] ${label} returned ${response.status}; retrying in ${retryDelay}ms`);
121
+ await delay(retryDelay);
122
+ } catch (error) {
123
+ lastError = error;
124
+ if (attempt === retries) throw error;
125
+
126
+ const retryDelay = Math.min(maxDelayMs, baseDelayMs * (2 ** attempt));
127
+ console.warn(`[retry] ${label} failed (${error.message}); retrying in ${retryDelay}ms`);
128
+ await delay(retryDelay);
129
+ }
130
+ }
131
+
132
+ throw lastError || new Error(`${label} failed.`);
133
+ }
134
+
135
+ function stripPhotoDataUrl(value) {
136
+ if (Array.isArray(value)) {
137
+ return value.map((item) => stripPhotoDataUrl(item));
138
+ }
139
+
140
+ if (!value || typeof value !== 'object') {
141
+ return value;
142
+ }
143
+
144
+ const clone = {};
145
+ for (const [key, child] of Object.entries(value)) {
146
+ if (key === 'photoDataUrl') continue;
147
+ clone[key] = stripPhotoDataUrl(child);
148
+ }
149
+ return clone;
150
+ }
151
+
152
+ function isStoredTokenFresh(boxConfig) {
153
+ const accessToken = normalizeString(boxConfig.accessToken);
154
+ if (!accessToken) return false;
155
+
156
+ const expiresAt = normalizeString(boxConfig.accessTokenExpiresAt);
157
+ if (!expiresAt) return true;
158
+
159
+ const expiryTime = Date.parse(expiresAt);
160
+ if (!Number.isFinite(expiryTime)) return true;
161
+ return expiryTime - TOKEN_REFRESH_SKEW_MS > Date.now();
162
+ }
163
+
164
+ function hasBoxAuth(boxConfig = {}) {
165
+ const authMode = normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase();
166
+ if (authMode === 'access_token') {
167
+ return !!normalizeString(boxConfig.accessToken);
168
+ }
169
+ if (authMode === 'client_credentials') {
170
+ return !!(
171
+ normalizeString(boxConfig.clientId) &&
172
+ normalizeString(boxConfig.clientSecret) &&
173
+ normalizeString(boxConfig.subjectType) &&
174
+ normalizeString(boxConfig.subjectId)
175
+ );
176
+ }
177
+ return !!(
178
+ normalizeString(boxConfig.accessToken) ||
179
+ (
180
+ normalizeString(boxConfig.refreshToken) &&
181
+ normalizeString(boxConfig.clientId) &&
182
+ normalizeString(boxConfig.clientSecret)
183
+ )
184
+ );
185
+ }
186
+
187
+ function computeBoxStatus(boxConfig = {}) {
188
+ const intakeFolderId = normalizeString(boxConfig.intakeFolderId);
189
+ const caseRootFolderId = normalizeString(boxConfig.caseRootFolderId || intakeFolderId);
190
+ const metadataScope = normalizeString(boxConfig.metadataScope);
191
+ const metadataTemplateKey = normalizeString(boxConfig.metadataTemplateKey);
192
+ return {
193
+ authMode: normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase(),
194
+ configured: hasBoxAuth(boxConfig) && !!intakeFolderId,
195
+ hasAuth: hasBoxAuth(boxConfig),
196
+ intakeFolderId,
197
+ caseRootFolderId,
198
+ metadataScope,
199
+ metadataTemplateKey,
200
+ hasCustomMetadataTemplate: !!(metadataScope && metadataTemplateKey),
201
+ hasIntakeFolderId: !!intakeFolderId,
202
+ hasCaseRootFolderId: !!caseRootFolderId,
203
+ usingTemporaryAccessToken: normalizeString(boxConfig.authMode).toLowerCase() === 'access_token',
204
+ };
205
+ }
206
+
207
+ function buildBoxConfigError(boxConfig = {}) {
208
+ const authMode = normalizeString(boxConfig.authMode || 'oauth_refresh').toLowerCase();
209
+ if (authMode === 'client_credentials') {
210
+ return 'Box client credentials are incomplete. Save client ID, client secret, subject type, subject ID, and the intake folder in settings.html.';
211
+ }
212
+ if (authMode === 'access_token') {
213
+ return 'Box access token is missing. Open settings.html and save the Box integration first.';
214
+ }
215
+ return 'Box OAuth refresh credentials are incomplete. Save the client ID, client secret, refresh token, and intake folder in settings.html.';
216
+ }
217
+
218
+ function createBoxService({ configStore }) {
219
+ let tokenRefreshPromise = null;
220
+
221
+ async function readBoxConfig() {
222
+ const config = await configStore.readConfig();
223
+ const fileBox = config.box || {};
224
+ // Env-var overrides so the Box CCG connection survives on hosts that wipe disk
225
+ // (HuggingFace Spaces, Render, Railway, etc.). Set these as the host's secrets.
226
+ const E = process.env;
227
+ const env = {};
228
+ if (E.BOX_AUTH_MODE) env.authMode = E.BOX_AUTH_MODE;
229
+ if (E.BOX_CLIENT_ID) env.clientId = E.BOX_CLIENT_ID;
230
+ if (E.BOX_CLIENT_SECRET) env.clientSecret = E.BOX_CLIENT_SECRET;
231
+ if (E.BOX_SUBJECT_TYPE) env.subjectType = E.BOX_SUBJECT_TYPE;
232
+ if (E.BOX_SUBJECT_ID) env.subjectId = E.BOX_SUBJECT_ID;
233
+ if (E.BOX_INTAKE_FOLDER_ID) env.intakeFolderId = E.BOX_INTAKE_FOLDER_ID;
234
+ if (E.BOX_CASE_ROOT_FOLDER_ID) env.caseRootFolderId = E.BOX_CASE_ROOT_FOLDER_ID;
235
+ if (E.BOX_METADATA_SCOPE) env.metadataScope = E.BOX_METADATA_SCOPE;
236
+ if (E.BOX_METADATA_TEMPLATE_KEY) env.metadataTemplateKey = E.BOX_METADATA_TEMPLATE_KEY;
237
+ return { ...fileBox, ...env };
238
+ }
239
+
240
+ async function getClientConfig() {
241
+ const boxConfig = await readBoxConfig();
242
+ return configStore.sanitizeBoxConfigForClient(boxConfig);
243
+ }
244
+
245
+ async function getStatus() {
246
+ return computeBoxStatus(await readBoxConfig());
247
+ }
248
+
249
+ async function saveConfig(patch = {}, options = {}) {
250
+ const updated = await configStore.queueMutation((config) => ({
251
+ ...config,
252
+ box: configStore.mergeBoxConfig(config.box, patch, options),
253
+ }));
254
+
255
+ return {
256
+ config: configStore.sanitizeBoxConfigForClient(updated.box),
257
+ status: computeBoxStatus(updated.box),
258
+ };
259
+ }
260
+
261
+ async function resetConfig() {
262
+ return saveConfig({}, { reset: true });
263
+ }
264
+
265
+ async function persistTokens(tokenPatch = {}) {
266
+ const updated = await configStore.queueMutation((config) => ({
267
+ ...config,
268
+ box: configStore.mergeBoxConfig(config.box, tokenPatch),
269
+ }));
270
+ return updated.box;
271
+ }
272
+
273
+ async function requestBoxTokenViaRefresh(boxConfig, { persist = true } = {}) {
274
+ const refreshToken = normalizeString(boxConfig.refreshToken);
275
+ const clientId = normalizeString(boxConfig.clientId);
276
+ const clientSecret = normalizeString(boxConfig.clientSecret);
277
+ if (!refreshToken || !clientId || !clientSecret) {
278
+ throw new Error(buildBoxConfigError(boxConfig));
279
+ }
280
+
281
+ const body = new URLSearchParams({
282
+ grant_type: 'refresh_token',
283
+ refresh_token: refreshToken,
284
+ client_id: clientId,
285
+ client_secret: clientSecret,
286
+ });
287
+
288
+ const response = await fetchWithRetry(BOX_OAUTH_TOKEN_URL, {
289
+ method: 'POST',
290
+ headers: {
291
+ 'Content-Type': 'application/x-www-form-urlencoded',
292
+ },
293
+ body,
294
+ }, { label: 'Box OAuth refresh token request' });
295
+
296
+ if (!response.ok) {
297
+ throw new Error(`Box token refresh failed (${response.status}): ${await readErrorMessage(response)}`);
298
+ }
299
+
300
+ const payload = await response.json();
301
+ const expiresAt = payload.expires_in
302
+ ? new Date(Date.now() + (Number(payload.expires_in) * 1000)).toISOString()
303
+ : '';
304
+
305
+ if (persist) {
306
+ await persistTokens({
307
+ accessToken: payload.access_token || '',
308
+ accessTokenExpiresAt: expiresAt,
309
+ refreshToken: payload.refresh_token || refreshToken,
310
+ });
311
+ }
312
+
313
+ return normalizeString(payload.access_token);
314
+ }
315
+
316
+ async function requestBoxTokenViaClientCredentials(boxConfig, { persist = true } = {}) {
317
+ const clientId = normalizeString(boxConfig.clientId);
318
+ const clientSecret = normalizeString(boxConfig.clientSecret);
319
+ const subjectType = normalizeString(boxConfig.subjectType);
320
+ const subjectId = normalizeString(boxConfig.subjectId);
321
+ if (!clientId || !clientSecret || !subjectType || !subjectId) {
322
+ throw new Error(buildBoxConfigError(boxConfig));
323
+ }
324
+
325
+ const body = new URLSearchParams({
326
+ grant_type: 'client_credentials',
327
+ client_id: clientId,
328
+ client_secret: clientSecret,
329
+ box_subject_type: subjectType,
330
+ box_subject_id: subjectId,
331
+ });
332
+
333
+ const response = await fetchWithRetry(BOX_OAUTH_TOKEN_URL, {
334
+ method: 'POST',
335
+ headers: {
336
+ 'Content-Type': 'application/x-www-form-urlencoded',
337
+ },
338
+ body,
339
+ }, { label: 'Box client credentials token request' });
340
+
341
+ if (!response.ok) {
342
+ throw new Error(`Box client credentials token request failed (${response.status}): ${await readErrorMessage(response)}`);
343
+ }
344
+
345
+ const payload = await response.json();
346
+ const expiresAt = payload.expires_in
347
+ ? new Date(Date.now() + (Number(payload.expires_in) * 1000)).toISOString()
348
+ : '';
349
+
350
+ if (persist) {
351
+ await persistTokens({
352
+ accessToken: payload.access_token || '',
353
+ accessTokenExpiresAt: expiresAt,
354
+ });
355
+ }
356
+
357
+ return normalizeString(payload.access_token);
358
+ }
359
+
360
+ async function resolveAccessToken(boxConfig = null, options = {}) {
361
+ const persist = options.persist !== false;
362
+ const effectiveConfig = boxConfig || await readBoxConfig();
363
+ const authMode = normalizeString(effectiveConfig.authMode || 'oauth_refresh').toLowerCase();
364
+ const canRefreshOAuth = !!(
365
+ normalizeString(effectiveConfig.refreshToken) &&
366
+ normalizeString(effectiveConfig.clientId) &&
367
+ normalizeString(effectiveConfig.clientSecret)
368
+ );
369
+
370
+ if (authMode === 'access_token') {
371
+ const token = normalizeString(effectiveConfig.accessToken);
372
+ if (!token) throw new Error(buildBoxConfigError(effectiveConfig));
373
+ return token;
374
+ }
375
+
376
+ if ((authMode === 'oauth_refresh' && canRefreshOAuth && normalizeString(effectiveConfig.accessTokenExpiresAt) && isStoredTokenFresh(effectiveConfig))
377
+ || (authMode === 'oauth_refresh' && !canRefreshOAuth && isStoredTokenFresh(effectiveConfig))
378
+ || (authMode === 'client_credentials' && normalizeString(effectiveConfig.accessTokenExpiresAt) && isStoredTokenFresh(effectiveConfig))) {
379
+ return normalizeString(effectiveConfig.accessToken);
380
+ }
381
+
382
+ if (tokenRefreshPromise) {
383
+ return tokenRefreshPromise;
384
+ }
385
+
386
+ tokenRefreshPromise = (async () => {
387
+ if (authMode === 'client_credentials') {
388
+ return requestBoxTokenViaClientCredentials(effectiveConfig, { persist });
389
+ }
390
+ return requestBoxTokenViaRefresh(effectiveConfig, { persist });
391
+ })();
392
+
393
+ try {
394
+ return await tokenRefreshPromise;
395
+ } finally {
396
+ tokenRefreshPromise = null;
397
+ }
398
+ }
399
+
400
+ async function boxRequestJson(boxPath, options = {}, overrideConfig = null) {
401
+ const boxConfig = overrideConfig || await readBoxConfig();
402
+ const accessToken = await resolveAccessToken(boxConfig, { persist: !overrideConfig });
403
+ const response = await fetchWithRetry(`${BOX_API_BASE}${boxPath}`, {
404
+ method: options.method || 'GET',
405
+ headers: {
406
+ Authorization: `Bearer ${accessToken}`,
407
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
408
+ ...(options.headers || {}),
409
+ },
410
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
411
+ }, { label: options.label || `Box ${options.method || 'GET'} ${boxPath}` });
412
+
413
+ if (!response.ok) {
414
+ throw new Error(`${options.label || 'Box request'} failed (${response.status}): ${await readErrorMessage(response)}`);
415
+ }
416
+
417
+ if (options.rawResponse) return response;
418
+ return response.json();
419
+ }
420
+
421
+ async function uploadBufferToBox({ folderId, fileName, buffer, contentType, boxConfig = null }) {
422
+ const effectiveConfig = boxConfig || await readBoxConfig();
423
+ const accessToken = await resolveAccessToken(effectiveConfig, { persist: !boxConfig });
424
+
425
+ const formData = new FormData();
426
+ formData.append('attributes', JSON.stringify({
427
+ name: fileName,
428
+ parent: { id: folderId },
429
+ }));
430
+ formData.append('file', new Blob([buffer], { type: contentType || 'application/octet-stream' }), fileName);
431
+
432
+ const response = await fetchWithRetry(BOX_UPLOAD_URL, {
433
+ method: 'POST',
434
+ headers: {
435
+ Authorization: `Bearer ${accessToken}`,
436
+ },
437
+ body: formData,
438
+ }, { label: `Box upload ${fileName}` });
439
+
440
+ if (!response.ok) {
441
+ if (response.status === 409) {
442
+ try {
443
+ const conflictData = await response.json();
444
+ const existingFileId = conflictData.context_info?.conflicts?.id
445
+ || conflictData.context_info?.conflicts?.[0]?.id;
446
+
447
+ if (existingFileId) {
448
+ console.log(`[BoxService] File "${fileName}" already exists (id: ${existingFileId}). Uploading new version.`);
449
+ const versionFormData = new FormData();
450
+ versionFormData.append('file', new Blob([buffer], { type: contentType || 'application/octet-stream' }), fileName);
451
+
452
+ const versionUrl = `https://upload.box.com/api/2.0/files/${existingFileId}/content`;
453
+ const versionResponse = await fetchWithRetry(versionUrl, {
454
+ method: 'POST',
455
+ headers: {
456
+ Authorization: `Bearer ${accessToken}`,
457
+ },
458
+ body: versionFormData,
459
+ }, { label: `Box upload new version ${fileName}` });
460
+
461
+ if (versionResponse.ok) {
462
+ const versionPayload = await versionResponse.json();
463
+ const entry = versionPayload.entries?.[0] || {};
464
+ return {
465
+ fileId: normalizeString(entry.id),
466
+ fileName: normalizeString(entry.name),
467
+ sharedLink: entry.shared_link?.url || null,
468
+ };
469
+ } else {
470
+ throw new Error(`Box upload new version failed (${versionResponse.status}): ${await readErrorMessage(versionResponse)}`);
471
+ }
472
+ }
473
+ } catch (conflictError) {
474
+ throw new Error(`Box upload conflict resolution failed: ${conflictError.message}`);
475
+ }
476
+ }
477
+ throw new Error(`Box upload failed (${response.status}): ${await readErrorMessage(response)}`);
478
+ }
479
+
480
+ const payload = await response.json();
481
+ const entry = payload.entries?.[0] || {};
482
+ return {
483
+ fileId: normalizeString(entry.id),
484
+ fileName: normalizeString(entry.name),
485
+ sharedLink: entry.shared_link?.url || null,
486
+ };
487
+ }
488
+
489
+ async function testConnection() {
490
+ const boxConfig = await readBoxConfig();
491
+ const status = computeBoxStatus(boxConfig);
492
+ if (!status.hasAuth) {
493
+ return { ok: false, error: buildBoxConfigError(boxConfig) };
494
+ }
495
+ if (!status.hasIntakeFolderId) {
496
+ return { ok: false, error: 'Incoming intake folder ID is required.' };
497
+ }
498
+
499
+ try {
500
+ const intakeFolder = await boxRequestJson(`/folders/${encodeURIComponent(status.intakeFolderId)}`, {
501
+ label: 'Box intake folder lookup',
502
+ }, boxConfig);
503
+
504
+ let caseRootFolder = null;
505
+ if (status.caseRootFolderId && status.caseRootFolderId !== status.intakeFolderId) {
506
+ caseRootFolder = await boxRequestJson(`/folders/${encodeURIComponent(status.caseRootFolderId)}`, {
507
+ label: 'Box case root folder lookup',
508
+ }, boxConfig);
509
+ }
510
+
511
+ return {
512
+ ok: true,
513
+ folderName: intakeFolder.name || '(unknown)',
514
+ caseRootFolderName: caseRootFolder?.name || intakeFolder.name || '(unknown)',
515
+ };
516
+ } catch (error) {
517
+ return { ok: false, error: error.message };
518
+ }
519
+ }
520
+
521
+ // Create an open, downloadable shared link and return its direct download_url
522
+ // (what Box Doc Gen needs to fetch+embed the photo as an image tag).
523
+ async function createDirectSharedLink(fileId, boxConfig) {
524
+ try {
525
+ const token = await resolveAccessToken(boxConfig);
526
+ const response = await fetchWithRetry(`${BOX_API_BASE}/files/${encodeURIComponent(fileId)}?fields=shared_link`, {
527
+ method: 'PUT',
528
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
529
+ body: JSON.stringify({ shared_link: { access: 'open', permissions: { can_download: true } } }),
530
+ }, { label: `Box shared link ${fileId}` });
531
+ if (!response.ok) return '';
532
+ const data = await response.json();
533
+ return normalizeString(data.shared_link?.download_url || data.shared_link?.url || '');
534
+ } catch (error) {
535
+ console.warn('[box-service] createDirectSharedLink failed:', error.message);
536
+ return '';
537
+ }
538
+ }
539
+
540
+ async function uploadPhoto({ caseId, fileBuffer, contentType = 'image/jpeg' }) {
541
+ const boxConfig = await readBoxConfig();
542
+ const status = computeBoxStatus(boxConfig);
543
+ if (!status.configured) {
544
+ throw new Error(buildBoxConfigError(boxConfig));
545
+ }
546
+
547
+ // Photos go to a SEPARATE "Pothole Photos" folder β€” not the trigger folder β€”
548
+ // so the photo upload never fires WF1 (which would crash the Extract Agent on an image).
549
+ let photoFolderId = status.intakeFolderId;
550
+ try {
551
+ const token = await resolveAccessToken(boxConfig);
552
+ photoFolderId = await getOrCreateFolder(status.intakeFolderId, 'Pothole Photos', token);
553
+ } catch (e) {
554
+ console.warn('[box-service] could not create Pothole Photos folder, using intake:', e.message);
555
+ }
556
+ const upload = await uploadBufferToBox({
557
+ folderId: photoFolderId,
558
+ fileName: `${caseId}_photo.jpg`,
559
+ buffer: fileBuffer,
560
+ contentType,
561
+ boxConfig,
562
+ });
563
+ // photoUrl = direct shared link to the photo (used in the JSON, the mail, and docs).
564
+ let photoUrl = await createDirectSharedLink(upload.fileId, boxConfig);
565
+ if (!photoUrl) {
566
+ // Box can briefly reject a shared link right after upload β€” retry once.
567
+ await new Promise((r) => setTimeout(r, 700));
568
+ photoUrl = await createDirectSharedLink(upload.fileId, boxConfig);
569
+ }
570
+ return { ...upload, photoUrl };
571
+ }
572
+
573
+ function buildSubmissionSidecar(metadata = {}, intakeFolderId = '') {
574
+ const sanitized = stripPhotoDataUrl(metadata);
575
+ const caseId = normalizeString(sanitized.caseId);
576
+ const submittedAt = normalizeString(sanitized.submittedAt) || nowIso();
577
+ const workflow = buildInitialWorkflow(
578
+ { ...sanitized, caseId, submittedAt },
579
+ { folderId: intakeFolderId, folderName: 'Incoming Detections' }
580
+ );
581
+
582
+ return {
583
+ caseId,
584
+ submittedAt,
585
+ ...sanitized,
586
+ workflow,
587
+ ...flattenWorkflowForBox(workflow),
588
+ };
589
+ }
590
+
591
+ async function uploadSidecar({ caseId, metadata }) {
592
+ const boxConfig = await readBoxConfig();
593
+ const status = computeBoxStatus(boxConfig);
594
+ if (!status.configured) {
595
+ throw new Error(buildBoxConfigError(boxConfig));
596
+ }
597
+
598
+ const sidecarPayload = buildSubmissionSidecar({ ...metadata, caseId }, status.intakeFolderId);
599
+ const sidecarBuffer = Buffer.from(JSON.stringify(sidecarPayload, null, 2), 'utf8');
600
+ const result = await uploadBufferToBox({
601
+ folderId: status.intakeFolderId,
602
+ fileName: `${caseId}_metadata.json`,
603
+ buffer: sidecarBuffer,
604
+ contentType: 'application/json',
605
+ boxConfig,
606
+ });
607
+
608
+ return {
609
+ ...result,
610
+ sidecarPayload,
611
+ };
612
+ }
613
+
614
+ async function generateAndUploadAuditReport({ caseId, caseRecord }) {
615
+ const boxConfig = await readBoxConfig();
616
+ const status = computeBoxStatus(boxConfig);
617
+ if (!status.configured) {
618
+ throw new Error(buildBoxConfigError(boxConfig));
619
+ }
620
+
621
+ const report = caseRecord.report || {};
622
+ const folderId = report.boxFolderId || status.caseRootFolderId || status.intakeFolderId;
623
+
624
+ const mdContent = `# Pothole Case Audit Report - ${caseId}
625
+ **Status:** Resolved
626
+ **Generated on:** ${nowIso()}
627
+
628
+ ---
629
+
630
+ ## 1. Intake Information
631
+ * **Case ID:** ${caseId}
632
+ * **Submission Timestamp:** ${report.submittedAt || caseRecord.createdAt || 'N/A'}
633
+ * **Location:** ${report.address || 'Unknown Address'}
634
+ * **GPS Coordinates:** ${report.location?.lat ?? 'N/A'}, ${report.location?.lng ?? 'N/A'}
635
+ * **Reporter Name:** ${report.anonymous ? 'Anonymous' : (report.reporterName || 'N/A')}
636
+ * **Reporter Contact:** ${report.anonymous ? 'Anonymous' : (report.reporterContact || 'N/A')}
637
+ * **Initial Description:** ${report.description || 'No description provided.'}
638
+ * **AI Initial Classification:** ${report.aiResult || 'N/A'}
639
+
640
+ ---
641
+
642
+ ## 2. Enrichment & Severity Triage
643
+ * **Enriched At:** ${report.enrichment?.enrichedAt || 'N/A'}
644
+ * **Severity Score:** ${report.enrichment?.severityScore ?? 'N/A'} / 100
645
+ * **Severity Level:** ${report.enrichment?.severityLevel || 'N/A'}
646
+ * **Ward / District:** ${report.ward || 'N/A'} / ${report.district || 'N/A'}
647
+ * **Maintenance Zone:** ${report.maintenanceZone || 'N/A'}
648
+ * **Duplicate Status:** ${report.enrichment?.duplicate?.isDuplicate ? `Duplicate (${report.enrichment.duplicate.duplicateCount} prior reports)` : 'New Incident'}
649
+ * **Weather Risk:** ${report.enrichment?.weather?.condition || 'N/A'} (Risk Level: ${report.enrichment?.weather?.riskLevel || 'N/A'})
650
+
651
+ ---
652
+
653
+ ## 3. Dispatch & Field Operations
654
+ * **Assigned Crew / Lead:** ${report.crewName || report.assignedTo || 'Unassigned'}
655
+ * **Work Status:** ${caseRecord.status || 'Resolved'}
656
+
657
+ ---
658
+
659
+ ## 4. Completion & Resolution Proof
660
+ * **Completion Timestamp:** ${report.resolvedAt || 'N/A'}
661
+ * **Resolution Notes:** ${report.resolutionNotes || 'No notes provided.'}
662
+ * **Materials Used / Quantity:** ${report.materialsUsed || 'N/A'}
663
+ * **Labor Hours Expended:** ${report.laborHours || 'N/A'}
664
+ * **Crew Digital Signature:** ${report.crewSignature || 'N/A'}
665
+
666
+ ---
667
+
668
+ ## 5. Visual Evidence (Before vs. After)
669
+ * **Before Photo (Intake):** ${report.photoFileId ? `[View Intake Photo](https://app.box.com/file/${report.photoFileId})` : 'N/A'}
670
+ * **After Photo (Resolution):** ${report.afterPhotoFileId ? `[View Repair Photo](https://app.box.com/file/${report.afterPhotoFileId})` : 'N/A'}
671
+
672
+ ---
673
+
674
+ ## 6. System Logs & Audit Trail
675
+ * **Created At:** ${caseRecord.createdAt || 'N/A'}
676
+ * **Last Updated At:** ${caseRecord.updatedAt || 'N/A'}
677
+ `;
678
+
679
+ const reportBuffer = Buffer.from(mdContent, 'utf8');
680
+ const fileName = `${caseId}_audit_report.md`;
681
+
682
+ return uploadBufferToBox({
683
+ folderId,
684
+ fileName,
685
+ buffer: reportBuffer,
686
+ contentType: 'text/markdown',
687
+ boxConfig,
688
+ });
689
+ }
690
+
691
+ // Locate the Box case folder for a case: prefer the stored boxFolderId, else
692
+ // search for the "Case-{caseId}" folder by name.
693
+ async function findCaseFolderId(caseId, caseRecord, boxConfig) {
694
+ const stored = caseRecord?.report?.boxFolderId || caseRecord?.boxFolderId;
695
+ if (stored) return String(stored);
696
+ try {
697
+ const params = new URLSearchParams({ query: `Case-${caseId}`, type: 'folder', limit: '10' });
698
+ const payload = await boxRequestJson(`/search?${params.toString()}`, {
699
+ label: `Box folder search ${caseId}`,
700
+ }, boxConfig);
701
+ const match = (payload.entries || []).find(
702
+ (e) => e.type === 'folder' && e.name === `Case-${caseId}`,
703
+ );
704
+ return match ? String(match.id) : null;
705
+ } catch (error) {
706
+ console.warn('[box-service] findCaseFolderId failed:', error.message);
707
+ return null;
708
+ }
709
+ }
710
+
711
+ // Flip the case folder's potholeCase metadata to status=resolved + resolvedAt,
712
+ // so the Box App "Resolved" view (which reads metadata) reflects the closeout.
713
+ async function markCaseResolvedInBox({ caseId, caseRecord, resolvedAt, resolvedBy } = {}) {
714
+ const id = String(caseId || caseRecord?.caseId || '').trim();
715
+ if (!id) return { skipped: true, reason: 'missing_case_id' };
716
+
717
+ const boxConfig = await readBoxConfig();
718
+ const status = computeBoxStatus(boxConfig);
719
+ if (!status.configured) return { skipped: true, reason: 'box_not_configured' };
720
+ if (!status.hasCustomMetadataTemplate) return { skipped: true, reason: 'no_metadata_template' };
721
+
722
+ const folderId = await findCaseFolderId(id, caseRecord, boxConfig);
723
+ if (!folderId) return { skipped: true, reason: 'case_folder_not_found' };
724
+
725
+ const token = await resolveAccessToken(boxConfig);
726
+ const template = { scope: boxConfig.metadataScope, templateKey: boxConfig.metadataTemplateKey };
727
+ const kv = {
728
+ status: 'resolved',
729
+ resolvedAt: resolvedAt || nowIso(),
730
+ supervisorWardEngineer: String(resolvedBy || '').trim(),
731
+ };
732
+ await applyFolderMetadata(folderId, kv, token, { template });
733
+ return { ok: true, folderId, applied: kv };
734
+ }
735
+
736
+ // Set any status on a case folder's metadata (Approve/Resolve/Reject/Reopen from the dashboard).
737
+ async function setCaseStatusInBox({ caseId, caseRecord, status, by } = {}) {
738
+ const id = String(caseId || caseRecord?.caseId || '').trim();
739
+ if (!id) return { skipped: true, reason: 'missing_case_id' };
740
+ const boxConfig = await readBoxConfig();
741
+ const st = computeBoxStatus(boxConfig);
742
+ if (!st.configured) return { skipped: true, reason: 'box_not_configured' };
743
+ if (!st.hasCustomMetadataTemplate) return { skipped: true, reason: 'no_metadata_template' };
744
+ const folderId = await findCaseFolderId(id, caseRecord, boxConfig);
745
+ if (!folderId) return { skipped: true, reason: 'case_folder_not_found' };
746
+ const token = await resolveAccessToken(boxConfig);
747
+ const template = { scope: boxConfig.metadataScope, templateKey: boxConfig.metadataTemplateKey };
748
+ const kv = { status };
749
+ if (by) kv.supervisorWardEngineer = String(by).trim();
750
+ if (String(status).toLowerCase() === 'resolved') kv.resolvedAt = nowIso();
751
+ await applyFolderMetadata(folderId, kv, token, { template });
752
+ return { ok: true, folderId, applied: kv };
753
+ }
754
+
755
+ async function uploadCaseArtifact({ caseId, caseRecord, fileName, content, contentType = 'text/plain; charset=utf-8' }) {
756
+ const boxConfig = await readBoxConfig();
757
+ const status = computeBoxStatus(boxConfig);
758
+ if (!status.configured) {
759
+ return {
760
+ skipped: true,
761
+ reason: 'box_not_configured',
762
+ };
763
+ }
764
+
765
+ const report = caseRecord?.report || {};
766
+ const folderId = report.boxFolderId || status.caseRootFolderId || status.intakeFolderId;
767
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8');
768
+
769
+ const upload = await uploadBufferToBox({
770
+ folderId,
771
+ fileName,
772
+ buffer,
773
+ contentType,
774
+ boxConfig,
775
+ });
776
+
777
+ return {
778
+ caseId: normalizeString(caseId || caseRecord?.caseId),
779
+ folderId,
780
+ ...upload,
781
+ };
782
+ }
783
+
784
+ // Ensure a single dedicated "Closeout Reports" folder under the case root. All
785
+ // before/after reports go here so a collaborator can watch ONE folder and get
786
+ // exactly one notification per closeout (no per-case-folder photo/audit noise).
787
+ const _closeoutReportsFolderCache = {};
788
+ const CLOSEOUT_REPORTS_FOLDER_NAME = 'Closeout Reports';
789
+ async function ensureCloseoutReportsFolder(boxConfig) {
790
+ const status = computeBoxStatus(boxConfig);
791
+ const rootId = normalizeString(status.caseRootFolderId || status.intakeFolderId);
792
+ if (!rootId) return '';
793
+ if (_closeoutReportsFolderCache[rootId]) return _closeoutReportsFolderCache[rootId];
794
+ const token = await resolveAccessToken(boxConfig);
795
+ const folderId = await getOrCreateFolder(rootId, CLOSEOUT_REPORTS_FOLDER_NAME, token);
796
+ _closeoutReportsFolderCache[rootId] = folderId;
797
+ return folderId;
798
+ }
799
+
800
+ // Upload a before/after report into the dedicated "Closeout Reports" folder.
801
+ async function uploadCloseoutReport({ caseId, fileName, content, contentType = 'application/octet-stream' }) {
802
+ const boxConfig = await readBoxConfig();
803
+ const status = computeBoxStatus(boxConfig);
804
+ if (!status.configured) return { skipped: true, reason: 'box_not_configured' };
805
+
806
+ const folderId = await ensureCloseoutReportsFolder(boxConfig);
807
+ if (!folderId) return { skipped: true, reason: 'no_root_folder' };
808
+
809
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8');
810
+ const upload = await uploadBufferToBox({ folderId, fileName, buffer, contentType, boxConfig });
811
+ return {
812
+ caseId: normalizeString(caseId),
813
+ folderId,
814
+ fileName,
815
+ ...upload,
816
+ };
817
+ }
818
+
819
+ // Upload a backend-generated document (.docx) to the case folder (or a dedicated
820
+ // "Pothole Documents" folder) β€” never the trigger root β€” and return a shared link.
821
+ async function uploadGeneratedDoc({ caseId, caseRecord, fileName, buffer }) {
822
+ const boxConfig = await readBoxConfig();
823
+ const status = computeBoxStatus(boxConfig);
824
+ if (!status.configured) throw new Error(buildBoxConfigError(boxConfig));
825
+
826
+ let folderId = await findCaseFolderId(caseId, caseRecord, boxConfig);
827
+ if (!folderId) {
828
+ const token = await resolveAccessToken(boxConfig);
829
+ folderId = await getOrCreateFolder(status.intakeFolderId, 'Pothole Documents', token);
830
+ }
831
+
832
+ const upload = await uploadBufferToBox({
833
+ folderId,
834
+ fileName,
835
+ buffer,
836
+ contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
837
+ boxConfig,
838
+ });
839
+
840
+ let sharedLink = '';
841
+ try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {}
842
+ if (!sharedLink) {
843
+ // Box can briefly reject a shared link right after upload β€” retry once.
844
+ await new Promise((r) => setTimeout(r, 700));
845
+ try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {}
846
+ }
847
+
848
+ return { caseId: normalizeString(caseId || caseRecord?.caseId), folderId, fileName, sharedLink, ...upload };
849
+ }
850
+
851
+ // Create a Box Sign request β€” emails the signer(s) a fillable doc to complete + sign (WF2 crew closeout).
852
+ async function createSignRequest({ sourceFileId, parentFolderId, signers, emailSubject, emailMessage } = {}) {
853
+ const boxConfig = await readBoxConfig();
854
+ const status = computeBoxStatus(boxConfig);
855
+ if (!status.configured) throw new Error(buildBoxConfigError(boxConfig));
856
+ if (!sourceFileId) throw new Error('sourceFileId is required for the sign request.');
857
+ if (!Array.isArray(signers) || !signers.length) throw new Error('At least one signer email is required.');
858
+ const token = await resolveAccessToken(boxConfig);
859
+ const body = {
860
+ source_files: [{ id: String(sourceFileId), type: 'file' }],
861
+ signers: signers.map((s, i) => ({ email: s.email, role: 'signer', order: i + 1 })),
862
+ };
863
+ if (parentFolderId) body.parent_folder = { id: String(parentFolderId), type: 'folder' };
864
+ if (emailSubject) body.email_subject = emailSubject;
865
+ if (emailMessage) body.email_message = emailMessage;
866
+ const response = await fetchWithRetry(`${BOX_API_BASE}/sign_requests`, {
867
+ method: 'POST',
868
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
869
+ body: JSON.stringify(body),
870
+ }, { label: 'Box Sign request' });
871
+ if (!response.ok) {
872
+ throw new Error(`Box Sign request failed (${response.status}): ${await readErrorMessage(response)}`);
873
+ }
874
+ const data = await response.json();
875
+ return {
876
+ ok: true,
877
+ signRequestId: data.id,
878
+ status: data.status,
879
+ signers: (data.signers || []).map((s) => s.email),
880
+ prepareUrl: data.prepare_url || '',
881
+ };
882
+ }
883
+
884
+ async function organizeCase(payload = {}) {
885
+ const boxConfig = await readBoxConfig();
886
+ const status = computeBoxStatus(boxConfig);
887
+ if (!status.configured) {
888
+ throw new Error(buildBoxConfigError(boxConfig));
889
+ }
890
+
891
+ const accessToken = await resolveAccessToken(boxConfig);
892
+ return organizeInBox({
893
+ caseId: normalizeString(payload.caseId),
894
+ ward: normalizeString(payload.ward),
895
+ district: normalizeString(payload.district),
896
+ photoFileId: normalizeString(payload.photoFileId),
897
+ sidecarFileId: normalizeString(payload.sidecarFileId),
898
+ enrichment: stripPhotoDataUrl(payload.enrichment || {}),
899
+ reportPayload: stripPhotoDataUrl(payload.reportPayload || {}),
900
+ rootFolderId: status.caseRootFolderId || status.intakeFolderId,
901
+ metadataTemplate: {
902
+ scope: normalizeString(boxConfig.metadataScope),
903
+ templateKey: normalizeString(boxConfig.metadataTemplateKey),
904
+ },
905
+ token: accessToken,
906
+ });
907
+ }
908
+
909
+ async function getFileContent(fileId) {
910
+ const boxConfig = await readBoxConfig();
911
+ const accessToken = await resolveAccessToken(boxConfig);
912
+ const response = await fetchWithRetry(`${BOX_API_BASE}/files/${encodeURIComponent(fileId)}/content`, {
913
+ method: 'GET',
914
+ headers: {
915
+ Authorization: `Bearer ${accessToken}`,
916
+ },
917
+ }, { label: `Box file content ${fileId}` });
918
+
919
+ if (!response.ok) {
920
+ throw new Error(`Box file preview failed (${response.status}): ${await readErrorMessage(response)}`);
921
+ }
922
+
923
+ return {
924
+ contentType: response.headers.get('content-type') || 'application/octet-stream',
925
+ contentLength: response.headers.get('content-length') || '',
926
+ body: Buffer.from(await response.arrayBuffer()),
927
+ };
928
+ }
929
+
930
+ // Look up a single case straight from Box and return its REAL status (read from
931
+ // the case folder's potholeCase metadata) so the citizen tracker reflects live
932
+ // supervisor decisions instead of being pinned to "Under Review".
933
+ async function searchCase(caseId) {
934
+ const id = String(caseId || '').trim();
935
+ if (!id) return null;
936
+ const boxConfig = await readBoxConfig();
937
+ const status = computeBoxStatus(boxConfig);
938
+ if (!status.configured) return null;
939
+
940
+ try {
941
+ const folderId = await findCaseFolderId(id, null, boxConfig);
942
+ if (!folderId) return null;
943
+
944
+ const scope = normalizeString(boxConfig.metadataScope) || 'enterprise';
945
+ const templateKey = normalizeString(boxConfig.metadataTemplateKey) || 'potholeCase';
946
+ let md = {};
947
+ try {
948
+ md = await boxRequestJson(
949
+ `/folders/${folderId}/metadata/${scope}/${templateKey}`,
950
+ { label: `Box case metadata ${id}` },
951
+ boxConfig,
952
+ );
953
+ } catch (_) {
954
+ // Folder exists but has no potholeCase metadata yet β€” treat as freshly submitted.
955
+ md = {};
956
+ }
957
+
958
+ const num = (v) => (v === undefined || v === null || v === '' ? null : Number(v));
959
+ const lat = num(md.latitude);
960
+ const lng = num(md.longitude);
961
+ return {
962
+ caseId: md.caseId || id,
963
+ status: normalizeString(md.status) || 'Under Review',
964
+ ts: md.submittedAt || Date.now(),
965
+ address: md.address || '',
966
+ ward: md.ward || '',
967
+ location: (lat != null && lng != null) ? { lat, lng } : null,
968
+ reporterContact: md.reporterContact || '',
969
+ potholeSize: md.potholeSize || '',
970
+ severityLevel: md.severityLevel || '',
971
+ severityScore: num(md.severityScore0100),
972
+ weatherRisk: md.weatherRisk || '',
973
+ district: md.district || '',
974
+ maintenanceZone: md.maintenanceZone || '',
975
+ resolvedAt: md.resolvedAt || '',
976
+ folderId,
977
+ source: 'box',
978
+ };
979
+ } catch (error) {
980
+ console.warn('[box-service] searchCase failed:', error.message);
981
+ }
982
+
983
+ return null;
984
+ }
985
+
986
+ // Pull the citizen's email out of the case folder's JSON sidecar(s). The submission
987
+ // sidecar ({caseId}_metadata.json) and enriched metadata carry reporterContact even
988
+ // when the Box metadata template field is empty β€” so read the JSON and extract it.
989
+ async function extractCitizenEmailFromCaseJson(caseId, folderId, boxConfig) {
990
+ try {
991
+ const items = await boxRequestJson(
992
+ `/folders/${folderId}/items?fields=id,name,type&limit=1000`,
993
+ { label: `case items ${caseId}` },
994
+ boxConfig,
995
+ );
996
+ const score = (name) => {
997
+ if (/_metadata\.json$/i.test(name) && !/enriched/i.test(name)) return 3; // submission sidecar
998
+ if (/enriched.*\.json$/i.test(name)) return 2;
999
+ return 1;
1000
+ };
1001
+ const jsonFiles = (items.entries || [])
1002
+ .filter((e) => e.type === 'file' && /\.json$/i.test(e.name))
1003
+ .sort((a, b) => score(b.name) - score(a.name));
1004
+
1005
+ for (const file of jsonFiles) {
1006
+ let text;
1007
+ try {
1008
+ const content = await getFileContent(file.id);
1009
+ text = content.body.toString('utf8');
1010
+ } catch (_) { continue; }
1011
+
1012
+ let email = '';
1013
+ try {
1014
+ const obj = JSON.parse(text);
1015
+ email = extractEmail(
1016
+ obj.reporterContact || obj.reporterEmail || obj.contact || obj.email
1017
+ || obj.report?.reporterContact || obj.report?.reporterEmail || obj.report?.contact || '',
1018
+ );
1019
+ } catch (_) { /* not valid JSON β€” fall through to a raw scan */ }
1020
+ if (!email) email = extractEmail(text);
1021
+ if (email) return email;
1022
+ }
1023
+ } catch (error) {
1024
+ console.warn('[box-service] extractCitizenEmailFromCaseJson failed:', error.message);
1025
+ }
1026
+ return '';
1027
+ }
1028
+
1029
+ // Read the submission sidecar JSON for a case and return the reporter + location data
1030
+ // captured at submission (reporterName, reporterContact, anonymous, address, location).
1031
+ // The metadata template often doesn't carry the reporter name, but the sidecar does.
1032
+ async function getCaseSubmissionData(caseId) {
1033
+ const id = String(caseId || '').trim();
1034
+ if (!id) return null;
1035
+ const boxConfig = await readBoxConfig();
1036
+ const status = computeBoxStatus(boxConfig);
1037
+ if (!status.configured) return null;
1038
+ const folderId = await findCaseFolderId(id, null, boxConfig);
1039
+ if (!folderId) return null;
1040
+ try {
1041
+ const items = await boxRequestJson(
1042
+ `/folders/${folderId}/items?fields=id,name,type&limit=1000`,
1043
+ { label: `case items ${id}` },
1044
+ boxConfig,
1045
+ );
1046
+ const score = (name) => (/_metadata\.json$/i.test(name) && !/enriched/i.test(name)) ? 3 : (/enriched.*\.json$/i.test(name) ? 2 : 1);
1047
+ const jsonFiles = (items.entries || [])
1048
+ .filter((e) => e.type === 'file' && /\.json$/i.test(e.name))
1049
+ .sort((a, b) => score(b.name) - score(a.name));
1050
+ for (const file of jsonFiles) {
1051
+ let obj;
1052
+ try { obj = JSON.parse((await getFileContent(file.id)).body.toString('utf8')); } catch (_) { continue; }
1053
+ const r = (obj && obj.report) ? obj.report : (obj || {});
1054
+ return {
1055
+ reporterName: normalizeString(r.reporterName || obj.reporterName),
1056
+ reporterContact: normalizeString(r.reporterContact || obj.reporterContact || r.reporterEmail || obj.reporterEmail),
1057
+ anonymous: (r.anonymous !== undefined ? r.anonymous : obj.anonymous),
1058
+ address: normalizeString(r.address || obj.address),
1059
+ location: r.location || obj.location || null,
1060
+ ward: normalizeString(r.ward || obj.ward),
1061
+ };
1062
+ }
1063
+ } catch (error) {
1064
+ console.warn('[box-service] getCaseSubmissionData failed:', error.message);
1065
+ }
1066
+ return null;
1067
+ }
1068
+
1069
+ // Notify a citizen of a status change *via Box*: drop a short status-update note
1070
+ // into the case folder, then collaborate the citizen (viewer) on that note with
1071
+ // notify=true so Box emails them a link β€” the same Box-native "share the summary"
1072
+ // path used elsewhere, so no separate email service is needed.
1073
+ async function notifyCitizenViaBox({ caseId, caseRecord, status, citizenEmail, summary } = {}) {
1074
+ const id = String(caseId || caseRecord?.caseId || '').trim();
1075
+ if (!id) return { skipped: true, reason: 'missing_case_id' };
1076
+
1077
+ const boxConfig = await readBoxConfig();
1078
+ const st = computeBoxStatus(boxConfig);
1079
+ if (!st.configured) return { skipped: true, reason: 'box_not_configured' };
1080
+
1081
+ const folderId = await findCaseFolderId(id, caseRecord, boxConfig);
1082
+ if (!folderId) return { skipped: true, reason: 'case_folder_not_found' };
1083
+
1084
+ // Extract the citizen email: first from whatever contact string we were handed,
1085
+ // then from the case-folder JSON sidecar (reporterContact lives in the JSON even
1086
+ // when the Box metadata field is blank).
1087
+ let email = extractEmail(citizenEmail || caseRecord?.report?.reporterContact || '');
1088
+ if (!email) email = await extractCitizenEmailFromCaseJson(id, folderId, boxConfig);
1089
+ if (!email) return { skipped: true, reason: 'no_citizen_email' };
1090
+
1091
+ const statusLabel = String(status || caseRecord?.status || 'Updated').trim();
1092
+ const stamp = nowIso().slice(0, 10);
1093
+ const fileName = `Status Update - Case ${id} - ${statusLabel} (${stamp}).txt`;
1094
+ const body = summary || buildCitizenStatusNote(id, statusLabel, caseRecord);
1095
+
1096
+ let upload;
1097
+ try {
1098
+ upload = await uploadBufferToBox({
1099
+ folderId,
1100
+ fileName,
1101
+ buffer: Buffer.from(body, 'utf8'),
1102
+ contentType: 'text/plain; charset=utf-8',
1103
+ boxConfig,
1104
+ });
1105
+ } catch (error) {
1106
+ return { ok: false, emailed: false, reason: 'note_upload_failed', error: error.message };
1107
+ }
1108
+
1109
+ let sharedLink = '';
1110
+ try { sharedLink = await createDirectSharedLink(upload.fileId, boxConfig); } catch (_) {}
1111
+
1112
+ // Add the citizen as a viewer on the note file -> Box emails them the invite/link.
1113
+ try {
1114
+ const collab = await boxRequestJson('/collaborations?notify=true', {
1115
+ method: 'POST',
1116
+ label: `Box notify citizen ${id}`,
1117
+ body: {
1118
+ item: { type: 'file', id: String(upload.fileId) },
1119
+ accessible_by: { type: 'user', login: email },
1120
+ role: 'viewer',
1121
+ },
1122
+ }, boxConfig);
1123
+ return {
1124
+ ok: true,
1125
+ emailed: true,
1126
+ to: email,
1127
+ status: statusLabel,
1128
+ fileId: upload.fileId,
1129
+ sharedLink,
1130
+ collaborationId: collab?.id || '',
1131
+ };
1132
+ } catch (error) {
1133
+ // The note is in Box even if the collaboration email failed (e.g. external
1134
+ // collaboration disabled) β€” surface that without failing the status update.
1135
+ return { ok: false, emailed: false, to: email, fileId: upload.fileId, sharedLink, error: error.message };
1136
+ }
1137
+ }
1138
+
1139
+ // Give a supervisor/ops recipient access to closeout reports *via Box*, as a ONE-TIME
1140
+ // invite. Rather than collaborating them on each case's report file (which would email
1141
+ // a fresh "invite to collaborate" for every case), we collaborate them once on the
1142
+ // stable case-root folder. Box emails that single invite the first time; on every
1143
+ // later case they are already a collaborator, so no repeat invite is sent β€” they just
1144
+ // have standing access, and we hand back a direct shared link to the new report.
1145
+ async function notifyCloseoutViaBox({ caseId, caseRecord, reportFileId, recipientEmail } = {}) {
1146
+ const id = String(caseId || caseRecord?.caseId || '').trim();
1147
+ if (!id) return { skipped: true, reason: 'missing_case_id' };
1148
+
1149
+ const email = extractEmail(recipientEmail || '');
1150
+ if (!email) return { skipped: true, reason: 'no_recipient_email' };
1151
+
1152
+ const boxConfig = await readBoxConfig();
1153
+ const st = computeBoxStatus(boxConfig);
1154
+ if (!st.configured) return { skipped: true, reason: 'box_not_configured' };
1155
+
1156
+ // Direct shared link to THIS case's report (handy for the response/UI regardless).
1157
+ const fileId = String(reportFileId || '').trim();
1158
+ let sharedLink = '';
1159
+ if (fileId) { try { sharedLink = await createDirectSharedLink(fileId, boxConfig); } catch (_) {} }
1160
+
1161
+ // Grant standing access to the dedicated "Closeout Reports" folder so the recipient
1162
+ // can watch exactly that one folder for new before/after reports.
1163
+ const rootFolderId = await ensureCloseoutReportsFolder(boxConfig);
1164
+ if (!rootFolderId) {
1165
+ // No stable workspace folder to grant standing access β€” the report link still works.
1166
+ return { ok: !!sharedLink, emailed: false, to: email, fileId, sharedLink, reason: 'no_root_folder' };
1167
+ }
1168
+
1169
+ // One-time collaboration on the Closeout Reports folder -> Box invites them once.
1170
+ try {
1171
+ const collab = await boxRequestJson('/collaborations?notify=true', {
1172
+ method: 'POST',
1173
+ label: `Box closeout access ${email}`,
1174
+ body: {
1175
+ item: { type: 'folder', id: rootFolderId },
1176
+ accessible_by: { type: 'user', login: email },
1177
+ role: 'viewer',
1178
+ },
1179
+ }, boxConfig);
1180
+ return { ok: true, emailed: true, firstTime: true, to: email, fileId, sharedLink, folderId: rootFolderId, collaborationId: collab?.id || '' };
1181
+ } catch (error) {
1182
+ // Already a collaborator -> they were invited once before. No new invite is sent;
1183
+ // they keep standing access to every report. This is the intended steady state.
1184
+ if (/already a collaborator/i.test(error.message || '')) {
1185
+ return { ok: true, emailed: false, alreadyHasAccess: true, to: email, fileId, sharedLink, folderId: rootFolderId };
1186
+ }
1187
+ // The report is saved in Box even if the collaboration failed (e.g. external
1188
+ // collaboration disabled) β€” surface that without failing the closeout.
1189
+ return { ok: false, emailed: false, to: email, fileId, sharedLink, error: error.message };
1190
+ }
1191
+ }
1192
+
1193
+ // List all cases straight from Box: walk Potholes/{ward}/Case-* and read each
1194
+ // case folder's potholeCase metadata (incl. severity breakdown) + its photo file id.
1195
+ async function listBoxCases() {
1196
+ const boxConfig = await readBoxConfig();
1197
+ const status = computeBoxStatus(boxConfig);
1198
+ if (!status.configured) return [];
1199
+ const rootId = status.caseRootFolderId || status.intakeFolderId;
1200
+
1201
+ const listItems = (folderId, label) =>
1202
+ boxRequestJson(`/folders/${folderId}/items?fields=id,name,type&limit=1000`, { label }, boxConfig)
1203
+ .then((r) => r.entries || [])
1204
+ .catch(() => []);
1205
+
1206
+ const rootItems = await listItems(rootId, 'list root');
1207
+ const potholes = rootItems.find((e) => e.type === 'folder' && e.name === 'Potholes');
1208
+ if (!potholes) return [];
1209
+
1210
+ const wards = (await listItems(potholes.id, 'list wards')).filter((e) => e.type === 'folder');
1211
+
1212
+ // List all wards' case folders in parallel, then fetch each case's metadata +
1213
+ // items through a bounded worker pool. Sequential calls took 100s+ with ~40
1214
+ // cases, which times out behind hosted proxies (e.g. HuggingFace Spaces).
1215
+ const wardFolderLists = await Promise.all(
1216
+ wards.map(async (ward) => (await listItems(ward.id, 'list cases'))
1217
+ .filter((e) => e.type === 'folder')
1218
+ .map((cf) => ({ ward, cf })))
1219
+ );
1220
+ const caseFolders = wardFolderLists.flat();
1221
+
1222
+ const num = (v) => (v === undefined || v === null || v === '' ? null : Number(v));
1223
+ const cases = [];
1224
+ let cursor = 0;
1225
+ const CONCURRENCY = 8;
1226
+ async function worker() {
1227
+ while (cursor < caseFolders.length) {
1228
+ const { ward, cf } = caseFolders[cursor++];
1229
+ let md;
1230
+ let items;
1231
+ try {
1232
+ [md, items] = await Promise.all([
1233
+ boxRequestJson(`/folders/${cf.id}/metadata/enterprise/potholeCase`, { label: 'case md' }, boxConfig),
1234
+ listItems(cf.id, 'case items'),
1235
+ ]);
1236
+ } catch (e) { continue; } // no potholeCase metadata -> not a case record
1237
+ const photo = items.find((e) => e.type === 'file' && /\.(jpe?g|png)$/i.test(e.name));
1238
+ cases.push({
1239
+ folderId: cf.id, folderName: cf.name, ward: ward.name,
1240
+ photoFileId: photo ? photo.id : '',
1241
+ caseId: md.caseId || cf.name.replace(/^Case-/, ''),
1242
+ status: md.status || '', severityLevel: md.severityLevel || '',
1243
+ potholeSize: md.potholeSize || '',
1244
+ district: md.district || '', maintenanceZone: md.maintenanceZone || '',
1245
+ aiReviewStatus: md.aiReviewStatus || '', reporterContact: md.reporterContact || '',
1246
+ severityScore: num(md.severityScore0100), address: md.address || '',
1247
+ submittedAt: md.submittedAt || '', resolvedAt: md.resolvedAt || '',
1248
+ duplicateStatus: (md.duplicateStatus || '').trim(),
1249
+ duplicateCount: num(md.duplicateCount) || 0, linkedCaseIds: md.linkedCaseIds || '',
1250
+ latitude: num(md.latitude), longitude: num(md.longitude),
1251
+ weatherRisk: md.weatherRisk || '', aiSummary: md.aiSummary || '',
1252
+ breakdown: {
1253
+ priorNotice: { score: num(md.priorNoticeScore), max: 30, detail: md.priorNoticeDetail || '' },
1254
+ traffic: { score: num(md.trafficScore), max: 25, detail: md.trafficDetail || '' },
1255
+ damage: { score: num(md.damageScore), max: 25, detail: md.damageDetail || '' },
1256
+ weather: { score: num(md.weatherScore), max: 10, detail: md.weatherDetail || '' },
1257
+ },
1258
+ });
1259
+ }
1260
+ }
1261
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, caseFolders.length || 1) }, worker));
1262
+ return cases;
1263
+ }
1264
+
1265
+ async function fetchOfficialBoundaries() {
1266
+ const response = await fetchWithRetry(DEFAULT_GIS_BOUNDARY_URL, {
1267
+ method: 'GET',
1268
+ headers: {
1269
+ Accept: 'application/geo+json, application/json;q=0.9, */*;q=0.1',
1270
+ },
1271
+ }, { label: 'Official GIS boundary fetch' });
1272
+
1273
+ if (!response.ok) {
1274
+ throw new Error(`Official GIS boundary fetch failed (${response.status}): ${await readErrorMessage(response)}`);
1275
+ }
1276
+
1277
+ return {
1278
+ contentType: response.headers.get('content-type') || 'application/json; charset=utf-8',
1279
+ body: await response.text(),
1280
+ };
1281
+ }
1282
+
1283
+ return {
1284
+ DEFAULT_GIS_BOUNDARY_URL,
1285
+ fetchWithRetry,
1286
+ stripPhotoDataUrl,
1287
+ getClientConfig,
1288
+ getStatus,
1289
+ saveConfig,
1290
+ resetConfig,
1291
+ testConnection,
1292
+ uploadPhoto,
1293
+ uploadSidecar,
1294
+ generateAndUploadAuditReport,
1295
+ markCaseResolvedInBox,
1296
+ setCaseStatusInBox,
1297
+ notifyCitizenViaBox,
1298
+ notifyCloseoutViaBox,
1299
+ getCaseSubmissionData,
1300
+ uploadCloseoutReport,
1301
+ createSignRequest,
1302
+ uploadCaseArtifact,
1303
+ uploadGeneratedDoc,
1304
+ organizeCase,
1305
+ getFileContent,
1306
+ searchCase,
1307
+ listBoxCases,
1308
+ fetchOfficialBoundaries,
1309
+ buildSubmissionSidecar,
1310
+ };
1311
+ }
1312
+
1313
+ module.exports = {
1314
+ createBoxService,
1315
+ fetchWithRetry,
1316
+ stripPhotoDataUrl,
1317
+ DEFAULT_GIS_BOUNDARY_URL,
1318
+ };
box-upload.js ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const API_PREFIX = '/api';
4
+
5
+ let __boxStatusCache = null;
6
+ let __boxStatusFetchedAt = 0;
7
+
8
+ function stripPhotoDataUrl(value) {
9
+ if (Array.isArray(value)) {
10
+ return value.map((item) => stripPhotoDataUrl(item));
11
+ }
12
+
13
+ if (!value || typeof value !== 'object') {
14
+ return value;
15
+ }
16
+
17
+ const clone = {};
18
+ for (const [key, child] of Object.entries(value)) {
19
+ if (key === 'photoDataUrl') continue;
20
+ clone[key] = stripPhotoDataUrl(child);
21
+ }
22
+ return clone;
23
+ }
24
+
25
+ async function parseApiResponse(response) {
26
+ let payload = null;
27
+ try {
28
+ payload = await response.json();
29
+ } catch {
30
+ payload = null;
31
+ }
32
+
33
+ if (!response.ok) {
34
+ throw new Error(payload?.error || payload?.message || `Request failed with HTTP ${response.status}`);
35
+ }
36
+
37
+ return payload;
38
+ }
39
+
40
+ async function apiRequest(path, options = {}) {
41
+ const response = await fetch(`${API_PREFIX}${path}`, {
42
+ method: options.method || 'GET',
43
+ headers: {
44
+ ...(options.rawBody ? {} : { 'Content-Type': 'application/json' }),
45
+ ...(options.headers || {}),
46
+ },
47
+ ...(options.body !== undefined ? { body: options.body } : {}),
48
+ });
49
+
50
+ return parseApiResponse(response);
51
+ }
52
+
53
+ async function getBoxStatus(options = {}) {
54
+ const { force = false } = options;
55
+ if (__boxStatusCache && !force && (Date.now() - __boxStatusFetchedAt) < 10000) {
56
+ return __boxStatusCache;
57
+ }
58
+
59
+ const payload = await apiRequest('/box/status', { method: 'GET' });
60
+ __boxStatusCache = payload?.status || {};
61
+ __boxStatusFetchedAt = Date.now();
62
+ return __boxStatusCache;
63
+ }
64
+
65
+ function buildMissingConfigMessage(status = {}) {
66
+ if (!status.hasAuth) {
67
+ return 'Box authentication is not configured on the backend. Open settings.html and save the Box integration first.';
68
+ }
69
+ if (!status.hasIntakeFolderId) {
70
+ return 'Incoming intake folder ID is not configured. Open settings.html and save the Box integration first.';
71
+ }
72
+ return 'Box is not configured on the backend. Open settings.html and save the Box integration first.';
73
+ }
74
+
75
+ async function testBoxConnection() {
76
+ try {
77
+ const payload = await apiRequest('/box/test-connection', {
78
+ method: 'POST',
79
+ body: JSON.stringify({}),
80
+ });
81
+ __boxStatusCache = null;
82
+ __boxStatusFetchedAt = 0;
83
+ return payload;
84
+ } catch (error) {
85
+ return { ok: false, error: error.message };
86
+ }
87
+ }
88
+
89
+ async function uploadPhoto(file, caseId) {
90
+ if (!file) {
91
+ throw new Error('Photo file is required.');
92
+ }
93
+
94
+ const status = await getBoxStatus();
95
+ if (!status.configured) {
96
+ throw new Error(buildMissingConfigMessage(status));
97
+ }
98
+
99
+ const payload = await apiRequest(`/box/upload-photo?caseId=${encodeURIComponent(caseId)}`, {
100
+ method: 'POST',
101
+ rawBody: true,
102
+ headers: {
103
+ 'Content-Type': file.type || 'application/octet-stream',
104
+ },
105
+ body: file,
106
+ });
107
+
108
+ return {
109
+ fileId: payload.fileId || '',
110
+ sharedLink: payload.sharedLink || null,
111
+ photoUrl: payload.photoUrl || '',
112
+ };
113
+ }
114
+
115
+ async function uploadSidecar(metadata, caseId) {
116
+ const status = await getBoxStatus();
117
+ if (!status.configured) {
118
+ throw new Error(buildMissingConfigMessage(status));
119
+ }
120
+
121
+ const payload = await apiRequest('/box/upload-sidecar', {
122
+ method: 'POST',
123
+ body: JSON.stringify({
124
+ caseId,
125
+ metadata: stripPhotoDataUrl(metadata || {}),
126
+ }),
127
+ });
128
+
129
+ return {
130
+ fileId: payload.fileId || '',
131
+ sidecarPayload: payload.sidecarPayload || null,
132
+ };
133
+ }
134
+
135
+ async function submitToBox(photoFile, metadata) {
136
+ const providedCaseId = metadata && typeof metadata.caseId === 'string'
137
+ ? metadata.caseId.trim()
138
+ : '';
139
+ const randomSuffix = Math.random()
140
+ .toString(36)
141
+ .toUpperCase()
142
+ .replace(/[^A-Z]/g, '')
143
+ .slice(0, 4)
144
+ .padEnd(4, 'X');
145
+
146
+ const caseId = providedCaseId || `PHX-${Date.now()}-${randomSuffix}`;
147
+ const submittedAt = metadata?.submittedAt || new Date().toISOString();
148
+ const sanitizedMetadata = stripPhotoDataUrl({
149
+ ...metadata,
150
+ caseId,
151
+ submittedAt,
152
+ });
153
+
154
+ try {
155
+ const photoResult = await uploadPhoto(photoFile, caseId);
156
+ const sidecarResult = await uploadSidecar({
157
+ ...sanitizedMetadata,
158
+ photoFileId: photoResult.fileId || '',
159
+ photoUrl: photoResult.photoUrl || '',
160
+ }, caseId);
161
+
162
+ return {
163
+ success: true,
164
+ caseId,
165
+ photoFileId: photoResult.fileId || '',
166
+ sidecarFileId: sidecarResult.fileId || '',
167
+ sidecarPayload: sidecarResult.sidecarPayload || sanitizedMetadata,
168
+ };
169
+ } catch (error) {
170
+ return {
171
+ success: false,
172
+ error: error instanceof Error ? error.message : String(error),
173
+ };
174
+ }
175
+ }
176
+
177
+ if (typeof module !== 'undefined' && module.exports) {
178
+ module.exports = {
179
+ getBoxStatus,
180
+ testBoxConnection,
181
+ uploadPhoto,
182
+ uploadSidecar,
183
+ submitToBox,
184
+ stripPhotoDataUrl,
185
+ };
186
+ }
187
+
188
+ if (typeof window !== 'undefined') {
189
+ window.getBoxStatus = getBoxStatus;
190
+ window.testBoxConnection = testBoxConnection;
191
+ window.submitToBox = submitToBox;
192
+ window.BoxUpload = {
193
+ getBoxStatus,
194
+ testBoxConnection,
195
+ uploadPhoto,
196
+ uploadSidecar,
197
+ submitToBox,
198
+ stripPhotoDataUrl,
199
+ };
200
+ }
box-workflow.js ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const BOX_WORKFLOW_TEMPLATE = 'pothole_box_ops_v1';
4
+
5
+ function workflowPriorityFromSeverity(severity) {
6
+ const normalized = String(severity || '').toLowerCase();
7
+ if (normalized === 'critical') return 'urgent';
8
+ if (normalized === 'high' || normalized === 'large') return 'high';
9
+ if (normalized === 'medium') return 'medium';
10
+ return 'normal';
11
+ }
12
+
13
+ function buildInitialWorkflow(report = {}, boxConfig = {}) {
14
+ const aiStatus = String(report.aiReviewStatus || '').toLowerCase();
15
+ const aiResult = String(report.aiResult || report.aiPrediction || '').toLowerCase();
16
+ const needsManualReview = aiStatus.includes('manual') || aiStatus.includes('face');
17
+ const needsAttention = aiStatus === 'invalid_image';
18
+
19
+ return {
20
+ template: BOX_WORKFLOW_TEMPLATE,
21
+ currentStage: 'incoming_detection',
22
+ currentStageLabel: 'Incoming Detection',
23
+ status: needsManualReview || needsAttention ? 'Under Review' : 'Submitted',
24
+ queue: needsManualReview || needsAttention ? 'Manual Review' : 'Ops Intake',
25
+ nextAction: needsAttention
26
+ ? 'Supervisor must validate the upload before enrichment'
27
+ : needsManualReview
28
+ ? 'Supervisor must verify the image before enrichment'
29
+ : 'Run enrichment and create the case folder',
30
+ priority: workflowPriorityFromSeverity(aiResult),
31
+ needsManualReview,
32
+ triggerFolderId: boxConfig.folderId || '',
33
+ triggerFolderName: boxConfig.folderName || 'Incoming Detections',
34
+ submittedAt: report.submittedAt || new Date().toISOString(),
35
+ lastUpdatedAt: report.submittedAt || new Date().toISOString(),
36
+ };
37
+ }
38
+
39
+ function buildEnrichedWorkflow(report = {}, enrichment = {}, context = {}) {
40
+ const severityLevel = String(enrichment.severityLevel || '').trim() || 'Pending';
41
+ const duplicateCount = enrichment.duplicate?.duplicateCount || 0;
42
+ const linkedCases = Array.isArray(enrichment.duplicate?.linkedCaseIds)
43
+ ? enrichment.duplicate.linkedCaseIds
44
+ : [];
45
+ const urgent = severityLevel.toLowerCase() === 'critical';
46
+
47
+ return {
48
+ template: BOX_WORKFLOW_TEMPLATE,
49
+ currentStage: 'supervisor_review',
50
+ currentStageLabel: 'Supervisor Review',
51
+ status: 'Under Review',
52
+ queue: urgent ? 'Emergency Dispatch' : 'Supervisor Review',
53
+ nextAction: urgent
54
+ ? 'Assign an emergency crew and dispatch immediately'
55
+ : duplicateCount > 0
56
+ ? 'Review duplicate cluster and confirm routing'
57
+ : 'Supervisor approve the case and send it to crew routing',
58
+ priority: workflowPriorityFromSeverity(severityLevel),
59
+ needsManualReview: String(report.aiReviewStatus || '').toLowerCase().includes('manual'),
60
+ severityLevel,
61
+ severityScore: enrichment.severityScore ?? '',
62
+ duplicateCount,
63
+ linkedCases: linkedCases.join(', '),
64
+ district: enrichment.district?.district || report.district || '',
65
+ ward: enrichment.district?.ward || report.ward || '',
66
+ maintenanceZone: enrichment.district?.maintenanceZone || report.maintenanceZone || '',
67
+ boxPath: context.folderPath || '',
68
+ rootFolderId: context.rootFolderId || '',
69
+ lastUpdatedAt: enrichment.enrichedAt || new Date().toISOString(),
70
+ };
71
+ }
72
+
73
+ function flattenWorkflowForBox(workflow = {}) {
74
+ return {
75
+ workflowTemplate: workflow.template || BOX_WORKFLOW_TEMPLATE,
76
+ workflowStage: workflow.currentStage || '',
77
+ workflowStageLabel: workflow.currentStageLabel || '',
78
+ workflowStatus: workflow.status || '',
79
+ workflowQueue: workflow.queue || '',
80
+ workflowNextAction: workflow.nextAction || '',
81
+ workflowPriority: workflow.priority || '',
82
+ workflowNeedsManualReview: workflow.needsManualReview ? 'true' : 'false',
83
+ workflowSeverityLevel: workflow.severityLevel || '',
84
+ workflowSeverityScore: workflow.severityScore ?? '',
85
+ workflowDuplicateCount: workflow.duplicateCount ?? '',
86
+ workflowLinkedCases: workflow.linkedCases || '',
87
+ workflowWard: workflow.ward || '',
88
+ workflowDistrict: workflow.district || '',
89
+ workflowMaintenanceZone: workflow.maintenanceZone || '',
90
+ workflowBoxPath: workflow.boxPath || '',
91
+ workflowLastUpdatedAt: workflow.lastUpdatedAt || '',
92
+ workflowTriggerFolderId: workflow.triggerFolderId || workflow.rootFolderId || '',
93
+ workflowTriggerFolderName: workflow.triggerFolderName || '',
94
+ };
95
+ }
96
+
97
+ function getWorkflowInputFields() {
98
+ return [
99
+ { key: 'caseId', source: 'Citizen upload', purpose: 'Primary record key used across Box, ops review, and tracking.' },
100
+ { key: 'submittedAt', source: 'Citizen upload', purpose: 'Timestamp for intake SLAs and queue sorting.' },
101
+ { key: 'photoFileId', source: 'Box upload', purpose: 'Original pothole photo file to preview or move into the case folder.' },
102
+ { key: 'sidecarFileId', source: 'Box upload', purpose: 'Original JSON sidecar used as the workflow payload.' },
103
+ { key: 'address', source: 'Citizen upload', purpose: 'Road location shown to reviewers and crew dispatch.' },
104
+ { key: 'areaLabel', source: 'Reverse geocode', purpose: 'Human-friendly locality label shown before official ward matching succeeds.' },
105
+ { key: 'ward', source: 'GIS or reverse geocode', purpose: 'Routes the case into the correct Box subfolder and maintenance area.' },
106
+ { key: 'district', source: 'GIS mapping', purpose: 'Maps the case to the review or engineering district.' },
107
+ { key: 'maintenanceZone', source: 'GIS mapping', purpose: 'Directs dispatch to the correct crew zone.' },
108
+ { key: 'duplicateStatus', source: 'Ops enrichment', purpose: 'Simple duplicate/new label used by external workflow decisions.' },
109
+ { key: 'weatherStatus', source: 'Ops enrichment', purpose: 'Weather risk status stored with the case for triage and reporting.' },
110
+ { key: 'potholeSize', source: 'Citizen upload AI', purpose: 'Human-readable pothole size used before and after enrichment.' },
111
+ { key: 'aiReviewStatus', source: 'Citizen upload AI', purpose: 'Determines whether the case should enter manual review first.' },
112
+ { key: 'aiResult', source: 'Citizen upload AI', purpose: 'Initial pothole size signal before enrichment recalculates severity.' },
113
+ { key: 'severityScore', source: 'Ops enrichment', purpose: 'Numeric urgency score for routing, triage, and reporting.' },
114
+ { key: 'severityLevel', source: 'Ops enrichment', purpose: 'Human-readable priority label for Box tasks and dashboards.' },
115
+ { key: 'isDuplicate', source: 'Ops enrichment', purpose: 'Flags repeat reports so teams can merge or escalate clusters.' },
116
+ { key: 'workflowStage', source: 'Workflow metadata', purpose: 'Stable machine field for the current lifecycle stage.' },
117
+ { key: 'workflowNextAction', source: 'Workflow metadata', purpose: 'Plain-language instruction shown to the next owner.' },
118
+ ];
119
+ }
120
+
121
+ function getWorkflowStages() {
122
+ return [
123
+ {
124
+ id: 'incoming_detection',
125
+ title: '1. Citizen intake and Box file storage',
126
+ use: 'The portal uploads the photo and JSON sidecar to Box, then upserts the same case into the backend queue.',
127
+ input: 'Folder ID from Settings, caseId, submittedAt, address, location, aiReviewStatus, potholeSize',
128
+ },
129
+ {
130
+ id: 'supervisor_review',
131
+ title: '2. Database-owned enrichment and review',
132
+ use: 'Ops Review and backend services calculate GIS, duplicates, weather, and severity, then save enriched artifacts back into Box.',
133
+ input: 'photoFileId, sidecarFileId, ward, district, maintenanceZone, duplicateStatus, weatherStatus, severityScore',
134
+ },
135
+ {
136
+ id: 'crew_dispatch',
137
+ title: '3. Database dispatch with Box-backed artifacts',
138
+ use: 'The backend records supervisor approval, authority routing, and crew dispatch while Box stores the generated notices and assignment files.',
139
+ input: 'severityLevel, duplicateStatus, workflowPriority, workflowStatus, maintenanceZone, assignedCrew',
140
+ },
141
+ {
142
+ id: 'resolved',
143
+ title: '4. Database closeout with Box proof storage',
144
+ use: 'Crew forms, after photos, reports, and audit outputs are saved by the backend and written back into Box for shared recordkeeping.',
145
+ input: 'resolvedAt, resolutionNotes, afterPhotoFileId, laborHours, materialsUsed, workflowStatus',
146
+ },
147
+ ];
148
+ }
149
+
150
+ function buildWorkflowBlueprint(boxConfig = {}) {
151
+ return {
152
+ template: BOX_WORKFLOW_TEMPLATE,
153
+ triggerFolderId: boxConfig.folderId || '',
154
+ triggerFolderName: boxConfig.folderName || 'Incoming Detections',
155
+ automationUses: [
156
+ 'Box as the content repository for intake, case folders, proof files, and reports',
157
+ 'JSON sidecar payload uploaded by the citizen portal',
158
+ 'Enriched metadata and readable summaries written back after backend processing',
159
+ 'Backend and app screens own approvals, dispatch, authority notifications, and closeout',
160
+ ],
161
+ inputFields: getWorkflowInputFields(),
162
+ stages: getWorkflowStages(),
163
+ metadataKeys: Object.keys(flattenWorkflowForBox({})),
164
+ };
165
+ }
166
+
167
+ if (typeof module !== 'undefined' && module.exports) {
168
+ module.exports = {
169
+ BOX_WORKFLOW_TEMPLATE,
170
+ buildInitialWorkflow,
171
+ buildEnrichedWorkflow,
172
+ flattenWorkflowForBox,
173
+ getWorkflowInputFields,
174
+ getWorkflowStages,
175
+ buildWorkflowBlueprint,
176
+ };
177
+ }
178
+
179
+ if (typeof window !== 'undefined') {
180
+ window.BoxWorkflow = {
181
+ BOX_WORKFLOW_TEMPLATE,
182
+ buildInitialWorkflow,
183
+ buildEnrichedWorkflow,
184
+ flattenWorkflowForBox,
185
+ getWorkflowInputFields,
186
+ getWorkflowStages,
187
+ buildWorkflowBlueprint,
188
+ };
189
+ }
case-report.html ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Before / After Report β€” PotholeIQ</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
10
+ <style>
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+ :root {
13
+ --ink-950:#0c1626; --ink-700:#344256; --ink-500:#64748b; --ink-400:#8a97a8;
14
+ --border:#e2e8f0; --brand:#0061d5; --mint:#0e9f6e; --amber:#b45309; --red:#dc2626;
15
+ }
16
+ body { font-family:'Inter',system-ui,sans-serif; background:#eef2f7; color:var(--ink-950); -webkit-font-smoothing:antialiased; }
17
+
18
+ /* Toolbar (not printed) */
19
+ .toolbar {
20
+ position:sticky; top:0; z-index:10;
21
+ display:flex; align-items:center; justify-content:space-between; gap:16px;
22
+ padding:14px 24px; background:rgba(12,22,38,0.96); color:#fff;
23
+ backdrop-filter:blur(10px);
24
+ }
25
+ .toolbar__brand { display:flex; align-items:center; gap:10px; font-weight:800; font-size:14px; }
26
+ .toolbar__brand-icon { width:30px; height:30px; border-radius:8px; background:linear-gradient(135deg,#0b66d6,#083f8c); display:flex; align-items:center; justify-content:center; font-weight:900; }
27
+ .toolbar__actions { display:flex; gap:10px; }
28
+ .tbtn { padding:9px 16px; border-radius:9px; font:inherit; font-size:13px; font-weight:700; cursor:pointer; border:1px solid rgba(255,255,255,0.18); background:rgba(255,255,255,0.08); color:#fff; text-decoration:none; transition:background .15s ease; }
29
+ .tbtn:hover { background:rgba(255,255,255,0.16); }
30
+ .tbtn.primary { background:linear-gradient(135deg,#1273e6,#0a55b4); border-color:transparent; }
31
+
32
+ .page { max-width:920px; margin:24px auto 60px; padding:0 20px; }
33
+ .sheet { background:#fff; border:1px solid var(--border); border-radius:16px; box-shadow:0 20px 50px rgba(15,23,42,0.10); overflow:hidden; }
34
+
35
+ .rhead { padding:28px 32px; border-bottom:1px solid var(--border); background:linear-gradient(135deg,#f8fbff,#eef4fb); }
36
+ .rhead__eyebrow { font-size:11px; font-weight:800; letter-spacing:0.12em; text-transform:uppercase; color:var(--brand); margin-bottom:6px; }
37
+ .rhead__title { font-size:26px; font-weight:900; letter-spacing:-0.03em; line-height:1.1; }
38
+ .rhead__meta { margin-top:8px; font-size:13px; color:var(--ink-500); display:flex; flex-wrap:wrap; gap:14px; }
39
+ .rhead__meta strong { color:var(--ink-700); }
40
+ .status-pill { display:inline-flex; align-items:center; gap:6px; padding:5px 12px; border-radius:999px; font-size:12px; font-weight:800; }
41
+
42
+ .summary { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--border); border-bottom:1px solid var(--border); }
43
+ .summary__cell { background:#fff; padding:16px 20px; }
44
+ .summary__label { font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:var(--ink-400); margin-bottom:5px; }
45
+ .summary__value { font-size:20px; font-weight:800; letter-spacing:-0.02em; }
46
+
47
+ .section { padding:24px 32px; border-bottom:1px solid var(--border); }
48
+ .section__label { font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-400); margin-bottom:14px; display:flex; align-items:center; gap:10px; }
49
+ .section__label::after { content:''; flex:1; height:1px; background:var(--border); }
50
+
51
+ .ba-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
52
+ .ba-col__tag { font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:8px; }
53
+ .ba-col__tag.before { color:var(--amber); }
54
+ .ba-col__tag.after { color:var(--mint); }
55
+ .ba-photo { width:100%; height:230px; border-radius:12px; object-fit:cover; border:1px solid var(--border); background:#f1f5f9; display:block; }
56
+ .ba-photo__ph { width:100%; height:230px; border-radius:12px; border:1.5px dashed var(--border); background:linear-gradient(135deg,#f8fafc,#eef2f7); display:flex; align-items:center; justify-content:center; text-align:center; color:var(--ink-400); font-size:12px; font-weight:600; padding:16px; }
57
+
58
+ .kv { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
59
+ .kv__item { border:1px solid var(--border); border-radius:11px; padding:11px 13px; background:#fbfdff; }
60
+ .kv__label { font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--ink-400); margin-bottom:4px; }
61
+ .kv__value { font-size:13px; font-weight:700; color:var(--ink-950); line-height:1.4; word-break:break-word; }
62
+
63
+ .cost-table { width:100%; border-collapse:collapse; font-size:13px; }
64
+ .cost-table td { padding:11px 14px; border-bottom:1px solid var(--border); }
65
+ .cost-table td:last-child { text-align:right; font-variant-numeric:tabular-nums; font-weight:700; white-space:nowrap; }
66
+ .cost-table tr.total td { border-top:2px solid var(--ink-950); border-bottom:none; font-weight:900; font-size:15px; }
67
+ .cost-table tr.total td:last-child { color:var(--amber); }
68
+ .cost-table .muted { color:var(--ink-500); font-weight:600; }
69
+ .variance-pos { color:var(--red); } .variance-neg { color:var(--mint); }
70
+
71
+ .notes { font-size:13.5px; line-height:1.7; color:var(--ink-700); background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:16px 18px; white-space:pre-wrap; }
72
+
73
+ .sign { display:grid; grid-template-columns:1fr 1fr; gap:24px; padding:24px 32px; }
74
+ .sign__line { border-top:1.5px solid var(--ink-700); padding-top:8px; font-size:12px; color:var(--ink-500); }
75
+ .sign__line strong { display:block; color:var(--ink-950); font-size:13px; }
76
+
77
+ .rfoot { padding:16px 32px; font-size:11px; color:var(--ink-400); text-align:center; }
78
+ .loading { padding:80px 20px; text-align:center; color:var(--ink-500); font-weight:600; }
79
+
80
+ @media (max-width:680px){ .summary{grid-template-columns:1fr 1fr;} .ba-grid,.kv,.sign{grid-template-columns:1fr;} }
81
+
82
+ @media print {
83
+ body { background:#fff; }
84
+ .toolbar { display:none; }
85
+ .page { margin:0; max-width:none; padding:0; }
86
+ .sheet { border:none; border-radius:0; box-shadow:none; }
87
+ .section, .summary, .rhead { break-inside:avoid; }
88
+ }
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <div class="toolbar">
93
+ <div class="toolbar__brand"><div class="toolbar__brand-icon">P</div> PotholeIQ</div>
94
+ <div class="toolbar__actions">
95
+ <a class="tbtn" href="ops-review.html">← Ops Review</a>
96
+ <button class="tbtn primary" onclick="window.print()">Print / Save PDF</button>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="page">
101
+ <div id="report-root" class="sheet">
102
+ <div class="loading" id="loading">Loading case report…</div>
103
+ </div>
104
+ </div>
105
+
106
+ <script src="backend-api.js?v=20260604b"></script>
107
+ <script>
108
+ 'use strict';
109
+ const CASES_KEY = 'submitted_cases';
110
+ const params = new URLSearchParams(location.search);
111
+ const caseId = (params.get('case') || '').trim();
112
+
113
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
114
+ const money = (v) => `$${num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
115
+ const esc = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
116
+ const dash = (v) => (v === 0 || v) ? v : 'β€”';
117
+
118
+ function estimateRepairCost(size, severityScore, address) {
119
+ const base = { Small: 140, Medium: 260, Large: 480 }[size] || 0;
120
+ if (!base) return { base: 0, severity: 0, traffic: 0, total: 0 };
121
+ const score = Math.max(0, Math.min(100, num(severityScore) || 50));
122
+ const severity = Math.round((score / 100) * 80);
123
+ const traffic = /(broad st|market st|chestnut st|ridge ave|main road|ring road|highway|expressway)/i.test(String(address || '')) ? Math.round(base * 0.12) : 0;
124
+ return { base, severity, traffic, total: Math.round((base + severity + traffic) / 5) * 5 };
125
+ }
126
+ function priorityFromScore(score) {
127
+ const s = num(score);
128
+ if (s >= 80) return { label:'Critical', desc:'Immediate (<12h)', color:'#dc2626', bg:'#fef2f2' };
129
+ if (s >= 60) return { label:'High', desc:'Next 24h', color:'#b45309', bg:'#fff8e6' };
130
+ if (s >= 40) return { label:'Medium', desc:'Next 72h', color:'#0061d5', bg:'#eff4ff' };
131
+ return { label:'Low', desc:'Scheduled (<7d)', color:'#0e9f6e', bg:'#e7f8f1' };
132
+ }
133
+ const STATUS_TONE = {
134
+ Resolved:{c:'#0e9f6e',bg:'#e7f8f1'}, 'In Progress':{c:'#b45309',bg:'#fff3e6'},
135
+ Assigned:{c:'#6d28d9',bg:'#f1edfc'}, 'Under Review':{c:'#b45309',bg:'#fff8e6'},
136
+ Submitted:{c:'#0061d5',bg:'#eaf1fb'}, Rejected:{c:'#9b1c1c',bg:'#fdf0ef'}
137
+ };
138
+
139
+ async function loadCase() {
140
+ if (window.PotholeBackend?.getCase) {
141
+ try { const c = await window.PotholeBackend.getCase(caseId); if (c) return c; } catch (e) { /* fall through */ }
142
+ }
143
+ try {
144
+ const local = JSON.parse(localStorage.getItem(CASES_KEY) || '[]');
145
+ return local.find((x) => x && x.caseId === caseId) || null;
146
+ } catch { return null; }
147
+ }
148
+
149
+ function photoCell(fileId, fileName, fallbackLabel) {
150
+ const id = `ph-${Math.random().toString(36).slice(2)}`;
151
+ if (fileId) {
152
+ // Try to load from Box; fall back to placeholder on error.
153
+ setTimeout(() => {
154
+ fetch(`/api/box/files/${encodeURIComponent(fileId)}/content`)
155
+ .then((r) => { if (!r.ok) throw new Error(); return r.blob(); })
156
+ .then((b) => { const el = document.getElementById(id); if (el) el.outerHTML = `<img class="ba-photo" src="${URL.createObjectURL(b)}" alt="${esc(fileName||'')}" />`; })
157
+ .catch(() => {});
158
+ }, 0);
159
+ return `<div class="ba-photo__ph" id="${id}">Loading image…<br>${esc(fileName || fileId)}</div>`;
160
+ }
161
+ return `<div class="ba-photo__ph">${esc(fallbackLabel)}${fileName ? `<br>${esc(fileName)}` : ''}</div>`;
162
+ }
163
+
164
+ function kv(label, value) {
165
+ return `<div class="kv__item"><div class="kv__label">${esc(label)}</div><div class="kv__value">${esc(dash(value))}</div></div>`;
166
+ }
167
+
168
+ function render(rec) {
169
+ const r = rec.report || {};
170
+ const sup = r.supervisor || {};
171
+ const enr = r.enrichment || {};
172
+ const bd = enr.severityBreakdown || {};
173
+ const loc = r.location || {};
174
+
175
+ const size = sup.size || r.potholeSize || r.aiResult || r.aiPrediction || 'Unknown';
176
+ const sevScore = enr.severityScore ?? r.workflowSeverityScore ?? '';
177
+ const sevLevel = enr.severityLevel || r.workflowSeverityLevel || 'Pending';
178
+ const pr = priorityFromScore(sevScore);
179
+
180
+ const laborHours = num(r.laborHours), laborRate = num(r.laborRate), materialsCost = num(r.materialsCost);
181
+ const laborCost = laborHours * laborRate;
182
+ const actualTotal = num(r.totalCost) || (laborCost + materialsCost);
183
+ const est = estimateRepairCost(size, sevScore, r.address);
184
+ const variance = est.total ? actualTotal - est.total : 0;
185
+ const variancePct = est.total ? ((variance / est.total) * 100).toFixed(1) + '%' : 'N/A';
186
+
187
+ const st = STATUS_TONE[rec.status] || STATUS_TONE.Submitted;
188
+ const roadScore = typeof r.aiRoadScore === 'number' ? (r.aiRoadScore * 100).toFixed(1) + '%' : 'β€”';
189
+ const dup = enr.duplicate?.isDuplicate ? `Duplicate (${enr.duplicate.duplicateCount || 0} linked)` : 'New case';
190
+ const supDecision = sup.verified ? (sup.isPothole ? 'Approved' : 'Rejected') : 'Pending';
191
+
192
+ document.getElementById('report-root').innerHTML = `
193
+ <div class="rhead">
194
+ <div class="rhead__eyebrow">Pothole Repair Β· Before / After Report</div>
195
+ <div class="rhead__title">${esc(rec.caseId)}</div>
196
+ <div class="rhead__meta">
197
+ <span><strong>Status:</strong> <span class="status-pill" style="background:${st.bg};color:${st.c};">${esc(rec.status || 'Resolved')}</span></span>
198
+ <span><strong>Address:</strong> ${esc(r.address || 'Unknown')}</span>
199
+ <span><strong>Generated:</strong> ${new Date().toLocaleString()}</span>
200
+ </div>
201
+ </div>
202
+
203
+ <div class="summary">
204
+ <div class="summary__cell"><div class="summary__label">AI Size</div><div class="summary__value">${esc(size)}</div></div>
205
+ <div class="summary__cell"><div class="summary__label">Severity</div><div class="summary__value">${dash(sevScore)}${sevScore!=='' ? '/100' : ''}</div></div>
206
+ <div class="summary__cell"><div class="summary__label">Priority</div><div class="summary__value" style="color:${pr.color}">${pr.label}</div></div>
207
+ <div class="summary__cell"><div class="summary__label">Total Spend</div><div class="summary__value" style="color:var(--amber)">${money(actualTotal)}</div></div>
208
+ </div>
209
+
210
+ <div class="section">
211
+ <div class="section__label">Before &amp; After</div>
212
+ <div class="ba-grid">
213
+ <div>
214
+ <div class="ba-col__tag before">● Before β€” Reported Condition</div>
215
+ ${photoCell(r.photoFileId, r.photoFileName, 'Reported photo not archived')}
216
+ </div>
217
+ <div>
218
+ <div class="ba-col__tag after">● After β€” Completed Repair</div>
219
+ ${photoCell(r.afterPhotoFileId, r.afterPhotoFileName, 'After-photo not uploaded')}
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ <div class="section">
225
+ <div class="section__label">Report &amp; Location</div>
226
+ <div class="kv">
227
+ ${kv('Submitted', r.submittedAt ? new Date(r.submittedAt).toLocaleString() : (rec.ts ? new Date(rec.ts).toLocaleString() : 'β€”'))}
228
+ ${kv('Reporter', r.anonymous ? 'Anonymous' : (r.reporterName || 'β€”'))}
229
+ ${kv('Ward / District', `${r.ward || 'Pending'} / ${r.district || 'Pending'}`)}
230
+ ${kv('Maintenance Zone', r.maintenanceZone || 'Pending')}
231
+ ${kv('Coordinates', (loc.lat != null && loc.lng != null) ? `${Number(loc.lat).toFixed(5)}, ${Number(loc.lng).toFixed(5)}` : 'β€”')}
232
+ ${kv('Reporter Contact', r.anonymous ? 'β€”' : (r.reporterContact || 'β€”'))}
233
+ </div>
234
+ </div>
235
+
236
+ <div class="section">
237
+ <div class="section__label">AI Assessment &amp; Severity (Before)</div>
238
+ <div class="kv">
239
+ ${kv('AI Classification', r.aiResult || r.aiPrediction || 'Unknown')}
240
+ ${kv('Road-likeness', roadScore)}
241
+ ${kv('Severity Level', `${sevLevel}${sevScore!=='' ? ` (${sevScore}/100)` : ''}`)}
242
+ ${kv('Resolution Priority', `${pr.label} β€” ${pr.desc}`)}
243
+ ${kv('311 Prior Notice', `${dash(bd.priorNotice?.score)} / ${bd.priorNotice?.max ?? 30}`)}
244
+ ${kv('Traffic & Road Class', `${dash(bd.traffic?.score)} / ${bd.traffic?.max ?? 25}`)}
245
+ ${kv('Damage Type', `${dash(bd.damage?.score)} / ${bd.damage?.max ?? 25}`)}
246
+ ${kv('Weather Risk', `${dash(bd.weather?.score)} / ${bd.weather?.max ?? 10}`)}
247
+ ${kv('Duplicate Status', dup)}
248
+ ${kv('Supervisor Decision', `${supDecision} Β· ${sup.by || 'β€”'}`)}
249
+ </div>
250
+ </div>
251
+
252
+ <div class="section">
253
+ <div class="section__label">Repair Completion (After)</div>
254
+ <div class="kv">
255
+ ${kv('Resolved At', r.resolvedAt ? new Date(r.resolvedAt).toLocaleString() : 'Pending')}
256
+ ${kv('Crew', r.crewName || r.dispatchPlan?.crewName || r.assignedTo || 'Pending')}
257
+ ${kv('Repair Method', r.repairMethod || 'Not specified')}
258
+ ${kv('Materials Used', r.materialsUsed || 'Not specified')}
259
+ ${kv('Labor Hours', r.laborHours ? `${r.laborHours} h` : 'Not specified')}
260
+ ${kv('Crew Signature', r.crewSignature || 'Not provided')}
261
+ </div>
262
+ </div>
263
+
264
+ <div class="section">
265
+ <div class="section__label">Cost β€” Estimated vs. Actual</div>
266
+ <table class="cost-table">
267
+ <tr><td>Estimated repair cost <span class="muted">(base ${money(est.base)} + severity ${money(est.severity)} + traffic ${money(est.traffic)})</span></td><td>${money(est.total)}</td></tr>
268
+ <tr><td>Actual labor <span class="muted">(${laborHours} h Γ— ${money(laborRate)}/hr)</span></td><td>${money(laborCost)}</td></tr>
269
+ <tr><td>Actual materials</td><td>${money(materialsCost)}</td></tr>
270
+ <tr class="total"><td>Actual total spend</td><td>${money(actualTotal)}</td></tr>
271
+ <tr><td>Variance vs. estimate</td><td class="${variance > 0 ? 'variance-pos' : 'variance-neg'}">${variance >= 0 ? '+' : ''}${money(variance)} (${variancePct})</td></tr>
272
+ </table>
273
+ </div>
274
+
275
+ <div class="section">
276
+ <div class="section__label">Field Notes</div>
277
+ <div class="notes">${esc(r.resolutionNotes || 'No field notes provided.')}</div>
278
+ </div>
279
+
280
+ <div class="sign">
281
+ <div class="sign__line"><strong>${esc(r.crewSignature || '________________')}</strong>Crew Lead β€” Repair Completed</div>
282
+ <div class="sign__line"><strong>${esc(sup.by || '________________')}</strong>Supervisor β€” Verified &amp; Closed</div>
283
+ </div>
284
+
285
+ <div class="rfoot">PotholeIQ Β· Department of Streets β€” Road Maintenance Division Β· Case ${esc(rec.caseId)}</div>
286
+ `;
287
+ }
288
+
289
+ (async function init() {
290
+ if (!caseId) {
291
+ document.getElementById('report-root').innerHTML = '<div class="loading">No case specified. Open this report from a case (e.g. case-report.html?case=POT-…).</div>';
292
+ return;
293
+ }
294
+ const rec = await loadCase();
295
+ if (!rec) {
296
+ document.getElementById('report-root').innerHTML = `<div class="loading">Case β€œ${esc(caseId)}” was not found.</div>`;
297
+ return;
298
+ }
299
+ render(rec);
300
+ })();
301
+ </script>
302
+ </body>
303
+ </html>
command-center.html ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>PotholeIQ β€” Command Center</title>
7
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
8
+ <style>
9
+ :root{
10
+ /* Box palette β€” white + blue only */
11
+ --bg:#f4f8fd; --panel:#ffffff; --panel2:#eef4fc; --line:#dbe6f5; --ink:#0b2545; --muted:#5b6b85;
12
+ --blue:#0061d5; --blue-deep:#003a85; --blue-mid:#3b86e6; --blue-soft:#8fbef0; --blue-pale:#c9e0f8;
13
+ /* severity = blue intensity scale (deep=critical … pale=low) */
14
+ --crit:#003a85; --high:#0061d5; --med:#4a93f0; --low:#a9cdf6;
15
+ }
16
+ *{box-sizing:border-box;margin:0;padding:0}
17
+ body{font-family:'Segoe UI',Inter,system-ui,Arial,sans-serif;background:var(--bg);color:var(--ink);padding:22px 26px 60px}
18
+ .top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px}
19
+ .brand{display:flex;align-items:center;gap:12px}
20
+ .logo{width:40px;height:40px;border-radius:11px;background:var(--blue);display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff}
21
+ h1{font-size:21px;font-weight:800;letter-spacing:.2px;color:var(--blue-deep)}
22
+ .sub{font-size:12.5px;color:var(--muted)}
23
+ .live{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--blue-deep);background:var(--panel2);border:1px solid var(--line);padding:5px 11px;border-radius:20px}
24
+ .live i{width:8px;height:8px;border-radius:50%;background:var(--blue);animation:p 1.6s infinite}
25
+ @keyframes p{0%{box-shadow:0 0 0 0 rgba(0,97,213,.5)}70%{box-shadow:0 0 0 7px rgba(0,97,213,0)}100%{box-shadow:0 0 0 0 rgba(0,97,213,0)}}
26
+ button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--panel);color:var(--blue);padding:7px 14px;border-radius:9px;font-weight:600}
27
+ button:hover{border-color:var(--blue);background:var(--panel2)}
28
+
29
+ .kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin-bottom:18px}
30
+ .kpi{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:15px 16px;position:relative;overflow:hidden;box-shadow:0 1px 3px rgba(11,37,69,.04)}
31
+ .kpi .v{font-size:30px;font-weight:800;line-height:1;color:var(--blue-deep)}
32
+ .kpi .l{font-size:12px;color:var(--muted);margin-top:6px}
33
+ .kpi .bar{position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--blue)}
34
+
35
+ .grid2{display:grid;grid-template-columns:1.55fr 1fr;gap:16px;margin-bottom:18px}
36
+ .grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}
37
+ .grid3 canvas{max-height:210px}
38
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px;box-shadow:0 1px 3px rgba(11,37,69,.04)}
39
+ .panel h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:12px}
40
+ #map{height:380px;border-radius:12px;overflow:hidden;border:1px solid var(--line)}
41
+ .leaflet-popup-content{font-family:inherit}
42
+
43
+ .donut-wrap{display:flex;align-items:center;gap:18px}
44
+ .donut{width:150px;height:150px;border-radius:50%;flex:none}
45
+ .legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
46
+ .legend .row{display:flex;align-items:center;gap:9px}
47
+ .legend .sw{width:12px;height:12px;border-radius:3px}
48
+ .pipe{margin-top:18px;display:flex;flex-direction:column;gap:9px}
49
+ .pipe .seg{display:flex;align-items:center;gap:10px;font-size:12.5px}
50
+ .pipe .track{flex:1;height:9px;background:var(--panel2);border-radius:6px;overflow:hidden}
51
+ .pipe .fill{height:100%;border-radius:6px;background:var(--blue)}
52
+ .pipe .n{width:26px;text-align:right;color:var(--muted)}
53
+
54
+ /* filter bar */
55
+ .cards-h{display:flex;align-items:center;justify-content:space-between;margin:8px 2px 12px;flex-wrap:wrap;gap:10px}
56
+ .cards-h h2{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.08em}
57
+ .filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-bottom:14px}
58
+ .fgroup{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
59
+ .fgroup .glab{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-right:2px}
60
+ .chip{font-size:12px;padding:5px 11px;border-radius:20px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}
61
+ .chip:hover{border-color:var(--blue-soft)}
62
+ .chip.active{background:var(--blue);color:#fff;border-color:var(--blue)}
63
+ select,input[type=search]{font:inherit;font-size:12.5px;padding:6px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--ink)}
64
+ input[type=search]{min-width:180px}
65
+ .fsep{width:1px;align-self:stretch;background:var(--line);margin:0 2px}
66
+
67
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
68
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 1px 3px rgba(11,37,69,.05)}
69
+ .card .photo{height:160px;background:var(--panel2);position:relative;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
70
+ .card .photo img{width:100%;height:100%;object-fit:cover;display:block}
71
+ .sev-badge{position:absolute;top:10px;left:10px;font-size:11px;font-weight:800;padding:4px 10px;border-radius:20px;color:#fff;text-transform:uppercase;letter-spacing:.04em}
72
+ .score-chip{position:absolute;top:10px;right:10px;font-size:13px;font-weight:800;background:rgba(0,58,133,.82);padding:4px 10px;border-radius:20px;color:#fff}
73
+ .card .photo[onclick]{cursor:pointer}
74
+ .open-box{position:absolute;bottom:10px;right:10px;background:rgba(11,37,69,.85);color:#fff;font-size:10.5px;font-weight:700;padding:4px 9px;border-radius:7px;opacity:0;transition:opacity .15s;pointer-events:none}
75
+ .card .photo:hover .open-box{opacity:1}
76
+ .card .body{padding:14px 15px;display:flex;flex-direction:column;gap:10px}
77
+ .card .cid{font-weight:800;font-size:15px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--blue-deep)}
78
+ .card .addr{font-size:12.5px;color:var(--muted)}
79
+ .tag{font-size:10.5px;padding:2px 8px;border-radius:6px;border:1px solid var(--line);color:var(--muted);background:var(--panel2)}
80
+ .tag.dup{color:var(--blue-deep);border-color:var(--blue-soft);background:var(--blue-pale)}
81
+ .tag.res{color:#fff;border-color:var(--blue);background:var(--blue)}
82
+
83
+ .bd{display:flex;flex-direction:column;gap:7px;margin-top:2px}
84
+ .bd .f{display:grid;grid-template-columns:78px 1fr 38px;align-items:center;gap:8px;font-size:11.5px}
85
+ .bd .f .lab{color:var(--muted)}
86
+ .bd .f .track{height:8px;background:var(--panel2);border-radius:5px;overflow:hidden}
87
+ .bd .f .fill{height:100%;border-radius:5px}
88
+ .bd .f .val{text-align:right;font-variant-numeric:tabular-nums;color:var(--ink)}
89
+ /* breakdown factors in blue shades */
90
+ .bd .f .fill.pn{background:var(--blue-deep)}.bd .f .fill.tr{background:var(--blue)}.bd .f .fill.dm{background:var(--blue-mid)}.bd .f .fill.we{background:var(--blue-soft)}
91
+ .summary{font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:9px;line-height:1.5}
92
+ .bd .f2{display:grid;grid-template-columns:92px 1fr;gap:10px;font-size:11.5px;align-items:baseline}
93
+ .bd .f2 .lab{color:var(--muted);font-weight:600}
94
+ .bd .f2 .dv{color:var(--ink);line-height:1.45}
95
+ .acts{display:flex;gap:6px;margin-top:11px;padding-top:11px;border-top:1px solid var(--line)}
96
+ .acts .act{flex:1;padding:8px 6px;border:none;border-radius:8px;font-size:11.5px;font-weight:700;cursor:pointer;transition:filter .15s}
97
+ .acts .act:hover{filter:brightness(.93)}
98
+ .acts .act:disabled{opacity:.55;cursor:default}
99
+ .acts .approve{background:var(--blue);color:#fff}
100
+ .acts .resolve{background:#16a366;color:#fff}
101
+ .acts .reject{background:#fff;color:#9b1c1c;border:1px solid #f0c5c5}
102
+ .acts .ghost{background:var(--panel2);color:var(--muted)}
103
+ .disp{display:flex;gap:6px;margin-top:8px}
104
+ .disp .crew-in{flex:1;min-width:0;padding:7px 9px;border:1px solid var(--line);border-radius:7px;font-size:11.5px;background:var(--bg);color:var(--ink)}
105
+ .disp .dbtn{padding:7px 11px;border:none;border-radius:7px;background:#0b3d91;color:#fff;font-size:11.5px;font-weight:700;cursor:pointer;white-space:nowrap}
106
+ .disp .dbtn:hover{filter:brightness(.92)}
107
+ .disp .dbtn:disabled{opacity:.55;cursor:default}
108
+ .nb-btn{margin-top:8px;padding:6px 10px;border:1px solid var(--line);background:var(--panel2);color:var(--blue-deep);font-size:11.5px;font-weight:700;border-radius:7px;cursor:pointer;width:100%}
109
+ .nb-btn:hover{border-color:var(--blue);background:#fff}
110
+ .modal{display:none;position:fixed;inset:0;background:rgba(11,37,69,.45);z-index:1000;align-items:center;justify-content:center;padding:20px}
111
+ .modal-box{background:var(--panel);border-radius:14px;max-width:540px;width:100%;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.3)}
112
+ .modal-head{display:flex;justify-content:space-between;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line);font-weight:800;color:var(--ink);font-size:13.5px}
113
+ .modal-x{border:none;background:var(--panel2);width:30px;height:30px;border-radius:8px;cursor:pointer;color:var(--muted);font-size:14px}
114
+ .nb-list{padding:6px 14px 12px;overflow:auto}
115
+ .nb-row{display:flex;align-items:center;gap:12px;padding:10px 4px;border-bottom:1px solid var(--line)}
116
+ .nb-row:last-child{border-bottom:none}
117
+ .nb-dist{min-width:70px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--ink);font-size:13px}
118
+ .nb-sev{font-size:10px;font-weight:800;color:#fff;padding:3px 8px;border-radius:12px;text-transform:uppercase;white-space:nowrap}
119
+ .nb-info{font-size:12px;color:var(--ink);line-height:1.4}
120
+ .nb-info .muted{color:var(--muted)}
121
+ .nb-dup{color:#9b1c1c;font-weight:800;font-size:10.5px}
122
+ .empty{text-align:center;color:var(--muted);padding:40px;border:1px dashed var(--line);border-radius:14px;grid-column:1/-1;background:var(--panel)}
123
+ @media(max-width:1000px){.kpis{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}}
124
+ </style>
125
+ </head>
126
+ <body>
127
+ <div class="top">
128
+ <div class="brand">
129
+ <div class="logo">πŸ›£οΈ</div>
130
+ <div>
131
+ <h1>PotholeIQ β€” Command Center</h1>
132
+ <div class="sub">Operations view Β· Philadelphia</div>
133
+ </div>
134
+ </div>
135
+ <div style="display:flex;gap:10px;align-items:center">
136
+ <span class="sub" id="who"></span>
137
+ <button onclick="load()">⟳ Refresh</button>
138
+ <button onclick="logout()">Sign out</button>
139
+ </div>
140
+ </div>
141
+
142
+ <div class="kpis" id="kpis"></div>
143
+
144
+ <div class="grid2">
145
+ <div class="panel">
146
+ <h2>Pothole Map β€” severity by location</h2>
147
+ <div id="map"></div>
148
+ </div>
149
+ <div class="panel">
150
+ <h2>Severity Distribution</h2>
151
+ <div class="donut-wrap"><div class="donut" id="donut"></div><div class="legend" id="legend"></div></div>
152
+ <div style="margin-top:18px"><h2>Status Pipeline</h2><div class="pipe" id="pipe"></div></div>
153
+ </div>
154
+ </div>
155
+
156
+ <div class="grid3">
157
+ <div class="panel"><h2>Cases by Ward</h2><canvas id="chWard"></canvas></div>
158
+ <div class="panel"><h2>Pothole Size</h2><canvas id="chSize"></canvas></div>
159
+ <div class="panel"><h2>Submissions Over Time</h2><canvas id="chTrend"></canvas></div>
160
+ </div>
161
+
162
+ <div class="cards-h">
163
+ <h2>Active Cases β€” severity breakdown</h2>
164
+ <span class="sub" id="count"></span>
165
+ </div>
166
+
167
+ <div class="filters" id="filters">
168
+ <div class="fgroup"><span class="glab">Severity</span><span id="sevChips"></span></div>
169
+ <div class="fsep"></div>
170
+ <div class="fgroup"><span class="glab">Status</span><span id="statChips"></span></div>
171
+ <div class="fsep"></div>
172
+ <div class="fgroup"><span class="glab">Ward</span><select id="wardSel"><option value="all">All</option></select></div>
173
+ <div class="fsep"></div>
174
+ <div class="fgroup"><input type="search" id="search" placeholder="Search case ID or address…" /></div>
175
+ </div>
176
+
177
+ <div class="cards" id="cards"></div>
178
+
179
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
180
+ <div id="nearby-modal" class="modal" onclick="if(event.target===this)closeNearby()">
181
+ <div class="modal-box">
182
+ <div class="modal-head"><span id="nearby-title">Nearby potholes</span><button class="modal-x" onclick="closeNearby()">βœ•</button></div>
183
+ <div id="nearby-list" class="nb-list"></div>
184
+ </div>
185
+ </div>
186
+
187
+
188
+ <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
189
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
190
+ <script>
191
+ // ── Supervisor auth gate ──────────────────────────────────────────────────
192
+ const SUPABASE_URL = 'https://knvoctdesiwogofcyced.supabase.co';
193
+ const SUPABASE_PUBLISHABLE = 'sb_publishable_pCMhOVA6YYg2948wi6ValQ_hhhC-xfK';
194
+ const sbAuth = supabase.createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE);
195
+ let ACCESS_TOKEN = '';
196
+ const REQUIRE_LOGIN = true; // supervisor login required (set false to open the dashboard)
197
+ async function logout(){ await sbAuth.auth.signOut(); location.href = 'login.html'; }
198
+ async function requireSupervisor(){
199
+ const { data } = await sbAuth.auth.getSession();
200
+ const s = data.session;
201
+ if (s && s.user?.app_metadata?.role === 'supervisor') {
202
+ ACCESS_TOKEN = s.access_token;
203
+ const who = document.getElementById('who'); if (who) who.textContent = s.user.email;
204
+ return true;
205
+ }
206
+ if (!REQUIRE_LOGIN) return true; // login disabled β€” load anyway
207
+ location.href = 'login.html'; return false; // login required β€” bounce to login
208
+ }
209
+ const authHeaders = () => ({ Authorization: `Bearer ${ACCESS_TOKEN}` });
210
+ </script>
211
+ <script>
212
+ const SEV = { critical:'#003a85', high:'#0061d5', medium:'#4a93f0', low:'#a9cdf6' };
213
+ const sevColor = s => SEV[(s||'').toLowerCase()] || '#8fbef0';
214
+ let map, caseLayer, allCases=[];
215
+ const filt = { sev:'all', status:'all', ward:'all', q:'' };
216
+
217
+ function kpi(v,l){return `<div class="kpi"><div class="bar"></div><div class="v">${v}</div><div class="l">${l}</div></div>`;}
218
+ function renderKpis(cs){
219
+ const open=cs.filter(c=>!['resolved','rejected'].includes((c.status||'').toLowerCase())).length;
220
+ const crit=cs.filter(c=>(c.severityLevel||'').toLowerCase()==='critical').length;
221
+ const res=cs.filter(c=>(c.status||'').toLowerCase()==='resolved').length;
222
+ const avg=cs.length?Math.round(cs.reduce((a,c)=>a+(c.severityScore||0),0)/cs.length):0;
223
+ document.getElementById('kpis').innerHTML=kpi(cs.length,'Total Cases')+kpi(open,'Open')+kpi(crit,'Critical')+kpi(res,'Resolved')+kpi(avg,'Avg Severity');
224
+ }
225
+ function renderDonut(cs){
226
+ const order=['critical','high','medium','low'];
227
+ const counts=order.map(k=>cs.filter(c=>(c.severityLevel||'').toLowerCase()===k).length);
228
+ const total=counts.reduce((a,b)=>a+b,0)||1; let acc=0,stops=[];
229
+ order.forEach((k,i)=>{const fr=counts[i]/total,a=acc*360,b=(acc+fr)*360;if(counts[i])stops.push(`${SEV[k]} ${a}deg ${b}deg`);acc+=fr;});
230
+ const dn=document.getElementById('donut');
231
+ dn.style.background=`conic-gradient(${stops.join(',')||'#dbe6f5 0 360deg'})`; dn.style.position='relative';
232
+ dn.innerHTML=`<div style="position:absolute;inset:26px;background:#fff;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center"><div style="font-size:26px;font-weight:800;color:var(--blue-deep)">${total}</div><div style="font-size:10px;color:var(--muted)">cases</div></div>`;
233
+ document.getElementById('legend').innerHTML=order.map((k,i)=>`<div class="row"><span class="sw" style="background:${SEV[k]}"></span><span style="text-transform:capitalize">${k}</span><b style="margin-left:auto">${counts[i]}</b></div>`).join('');
234
+ }
235
+ function renderPipe(cs){
236
+ const stages=[['under review','Under Review'],['assigned','Assigned'],['In progress','In Progress'],['resolved','Resolved']];
237
+ const max=cs.length||1;
238
+ document.getElementById('pipe').innerHTML=stages.map(([k,lab])=>{const n=cs.filter(c=>(c.status||'').toLowerCase()===k.toLowerCase()).length;return `<div class="seg"><span style="width:92px;color:var(--muted)">${lab}</span><div class="track"><div class="fill" style="width:${n/max*100}%"></div></div><span class="n">${n}</span></div>`;}).join('');
239
+ }
240
+ function bar(lab,cls,o){const s=o.score??0,m=o.max||1;return `<div class="f"><span class="lab">${lab}</span><div class="track"><div class="fill ${cls}" style="width:${Math.min(100,s/m*100)}%"></div></div><span class="val">${s}/${m}</span></div>`;}
241
+ // Estimated municipal repair cost by pothole size (US 2026: cold patch ~$90–180, hot-mix ~$250–550, full-depth up to ~$800).
242
+ function estCost(size){const s=(size||'').toLowerCase();
243
+ if(s.includes('large')) return '$450–$800 Β· hot-mix, full-depth';
244
+ if(s.includes('medium')) return '$250–$450 Β· hot-mix patch';
245
+ if(s.includes('small')) return '$90–$180 Β· cold patch';
246
+ return 'β€”';}
247
+ // Approve / Resolve / Reject / Reopen β€” writes the status to Box, then re-renders.
248
+ async function setStatus(caseId,status,btn){
249
+ const lbl=btn?btn.textContent:''; if(btn){btn.disabled=true;btn.textContent='…';}
250
+ try{
251
+ const r=await fetch('/api/box/set-status',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authHeaders()),body:JSON.stringify({caseId,status})}).then(x=>x.json());
252
+ if(!r.ok) throw new Error(r.error||r.reason||'update failed');
253
+ load();
254
+ }catch(e){ alert('Could not update '+caseId+': '+e.message); if(btn){btn.disabled=false;btn.textContent=lbl;} }
255
+ }
256
+ function card(c){
257
+ const sev=(c.severityLevel||'β€”'),col=sevColor(sev);
258
+ const img=c.photoFileId?`<img src="/api/box/files/${c.photoFileId}/content?access_token=${encodeURIComponent(ACCESS_TOKEN)}" onerror="this.style.display='none';this.parentElement.insertAdjacentHTML('beforeend','<span>πŸ“· photo in Box</span>')"/>`:'<span>πŸ“· no photo</span>';
259
+ const dup=(c.duplicateStatus||'').trim()==='duplicate'?`<span class="tag dup">⚠ duplicate Γ—${c.duplicateCount}</span>`:'';
260
+ const res=(c.status||'').toLowerCase()==='resolved'?`<span class="tag res">βœ“ resolved</span>`:`<span class="tag">${c.status||'β€”'}</span>`;
261
+ const b=c.breakdown||{};
262
+ return `<div class="card"><div class="photo" ${c.folderId?`onclick="openBox('${c.folderId}')" title="Open case folder in Box"`:''}><span class="sev-badge" style="background:${col}">${sev}</span><span class="score-chip">${c.severityScore??'β€”'}/100</span>${img}${c.folderId?'<span class="open-box">Open in Box β†—</span>':''}</div>
263
+ <div class="body"><div class="cid">${c.caseId} ${dup} ${res}</div><div class="addr">πŸ“ Ward ${c.ward} Β· ${c.address||'β€”'}</div>
264
+ <div class="addr" style="margin-top:3px">πŸ—“ Reported ${c.submittedAt?new Date(c.submittedAt).toLocaleDateString():'β€”'}</div>
265
+ <button class="nb-btn" onclick="showNearby('${c.caseId}')">πŸ“ See nearby potholes</button>
266
+ <div class="bd">
267
+ <div class="f2"><span class="lab">Prior detection</span><span class="dv">${(b.priorNotice&&b.priorNotice.detail)||'β€”'}</span></div>
268
+ <div class="f2"><span class="lab">Traffic / road</span><span class="dv">${(b.traffic&&b.traffic.detail)||'β€”'}</span></div>
269
+ <div class="f2"><span class="lab">Weather risk</span><span class="dv">${(b.weather&&b.weather.detail)||'β€”'}</span></div>
270
+ <div class="f2"><span class="lab">Pothole size</span><span class="dv">${c.potholeSize||'β€”'}</span></div>
271
+ <div class="f2"><span class="lab">Est. repair cost</span><span class="dv">${estCost(c.potholeSize)}</span></div>
272
+ </div>
273
+ <div class="acts">${['resolved','rejected'].includes((c.status||'').toLowerCase())
274
+ ? `<button class="act ghost" onclick="setStatus('${c.caseId}','under review',this)">β†Ί Reopen</button>`
275
+ : `<button class="act approve" onclick="setStatus('${c.caseId}','assigned',this)">βœ“ Approve</button><button class="act resolve" onclick="setStatus('${c.caseId}','resolved',this)">βœ“ Resolve</button><button class="act reject" onclick="setStatus('${c.caseId}','rejected',this)">βœ• Reject</button>`}</div>
276
+ <div class="disp"><input type="email" class="crew-in" id="crew-${c.caseId}" placeholder="crew@email.com" /><button class="dbtn" onclick="dispatchCrew('${c.caseId}',this)">Send to crew</button></div>
277
+ <a class="nb-btn" style="margin-top:8px;display:block;text-align:center;text-decoration:none" href="crew-closeout.html?case=${encodeURIComponent(c.caseId)}" target="_blank">Open Crew Closeout Form</a>
278
+ </div></div>`;
279
+ }
280
+ function renderMap(cs,boundaries){
281
+ if(!map){
282
+ map=L.map('map',{zoomControl:true}).setView([39.99,-75.13],11);
283
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{attribution:'Β© OpenStreetMap Β© CARTO',maxZoom:19}).addTo(map);
284
+ if(boundaries&&boundaries.features){L.geoJSON(boundaries,{style:{color:'#0061d5',weight:1,opacity:.4,fillOpacity:.04}}).addTo(map);}
285
+ }
286
+ if(caseLayer)caseLayer.remove(); caseLayer=L.layerGroup().addTo(map);
287
+ cs.forEach(c=>{if(c.latitude&&c.longitude){L.circleMarker([c.latitude,c.longitude],{radius:9,color:'#fff',weight:2,fillColor:sevColor(c.severityLevel),fillOpacity:.95}).bindPopup(`<b>${c.caseId}</b><br>${c.severityLevel} Β· ${c.severityScore}/100<br>Ward ${c.ward}`).addTo(caseLayer);}});
288
+ }
289
+
290
+ // ---------- filtering ----------
291
+ function applyFilters(){
292
+ const f=allCases.filter(c=>{
293
+ if(filt.sev!=='all' && (c.severityLevel||'').toLowerCase()!==filt.sev) return false;
294
+ if(filt.status!=='all' && (c.status||'').toLowerCase()!==filt.status.toLowerCase()) return false;
295
+ if(filt.ward!=='all' && String(c.ward)!==filt.ward) return false;
296
+ if(filt.q){const q=filt.q.toLowerCase();if(!(`${c.caseId} ${c.address}`.toLowerCase().includes(q)))return false;}
297
+ return true;
298
+ });
299
+ document.getElementById('count').textContent=`${f.length} of ${allCases.length} cases`;
300
+ document.getElementById('cards').innerHTML=f.length?f.sort((a,b)=>(b.severityScore||0)-(a.severityScore||0)).map(card).join(''):'<div class="empty">No cases match these filters.</div>';
301
+ renderMap(f);
302
+ }
303
+ function buildChips(){
304
+ const sevs=[['all','All'],['critical','Critical'],['high','High'],['medium','Medium'],['low','Low']];
305
+ document.getElementById('sevChips').innerHTML=sevs.map(([v,l])=>`<span class="chip ${filt.sev===v?'active':''}" data-k="sev" data-v="${v}">${l}</span>`).join('');
306
+ const stats=[['all','All'],['under review','Review'],['assigned','Assigned'],['In progress','In Progress'],['resolved','Resolved'],['rejected','Rejected']];
307
+ document.getElementById('statChips').innerHTML=stats.map(([v,l])=>`<span class="chip ${filt.status===v?'active':''}" data-k="status" data-v="${v}">${l}</span>`).join('');
308
+ document.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>{filt[ch.dataset.k]=ch.dataset.v;buildChips();applyFilters();});
309
+ const wards=[...new Set(allCases.map(c=>String(c.ward)))].sort((a,b)=>(parseInt(a)||999)-(parseInt(b)||999));
310
+ document.getElementById('wardSel').innerHTML='<option value="all">All</option>'+wards.map(w=>`<option value="${w}" ${filt.ward===w?'selected':''}>Ward ${w}</option>`).join('');
311
+ document.getElementById('wardSel').onchange=e=>{filt.ward=e.target.value;applyFilters();};
312
+ document.getElementById('search').oninput=e=>{filt.q=e.target.value;applyFilters();};
313
+ }
314
+
315
+ // Click a case photo -> open that case's folder in Box.
316
+ function openBox(folderId){ if(folderId) window.open('https://sednademo.app.box.com/folder/'+folderId,'_blank','noopener'); }
317
+
318
+ // Nearby potholes: closest cases by GPS distance (flags any within the 80 m duplicate range).
319
+ function haversineM(la1,lo1,la2,lo2){const R=6371000,r=x=>x*Math.PI/180;const dla=r(la2-la1),dlo=r(lo2-lo1);const a=Math.sin(dla/2)**2+Math.cos(r(la1))*Math.cos(r(la2))*Math.sin(dlo/2)**2;return 2*R*Math.asin(Math.sqrt(a));}
320
+ function showNearby(caseId){
321
+ const c=allCases.find(x=>x.caseId===caseId);
322
+ if(!c||c.latitude==null||c.longitude==null){alert('No GPS location recorded for '+caseId+'.');return;}
323
+ const fmt=m=>m>=1000?(m/1000).toFixed(2)+' km':m+' m';
324
+ const near=allCases.filter(x=>x.caseId!==caseId&&x.latitude!=null&&x.longitude!=null)
325
+ .map(x=>({c:x,dist:Math.round(haversineM(c.latitude,c.longitude,x.latitude,x.longitude))}))
326
+ .sort((a,b)=>a.dist-b.dist).slice(0,12);
327
+ document.getElementById('nearby-title').textContent='Potholes near '+caseId+' β€” '+near.length+' closest';
328
+ document.getElementById('nearby-list').innerHTML = near.length
329
+ ? near.map(n=>`<div class="nb-row"><span class="nb-dist">${fmt(n.dist)}${n.dist<=80?'<br><span class="nb-dup">⚠ duplicate</span>':''}</span><span class="nb-sev" style="background:${sevColor(n.c.severityLevel)}">${n.c.severityLevel||'β€”'}</span><div class="nb-info"><b>${n.c.caseId}</b> Β· ${n.c.status||'β€”'} Β· ${n.c.potholeSize||'β€”'}<br><span class="muted">πŸ“ Ward ${n.c.ward} Β· ${n.c.address||'β€”'}</span></div></div>`).join('')
330
+ : '<div class="nb-info muted" style="padding:14px">No other potholes with a location on record.</div>';
331
+ document.getElementById('nearby-modal').style.display='flex';
332
+ }
333
+ function closeNearby(){document.getElementById('nearby-modal').style.display='none';}
334
+
335
+ // The Box Form (closeout) the crew fills after repair -> filling it triggers WF2.
336
+ const CLOSEOUT_FORM_LINK='https://sednademo.app.box.com/f/bb3c86290ee94d7b8263bb9a33718e20';
337
+ // Enter a crew email -> generate the Work Order, then draft an email to the crew + supervisor
338
+ // with the Work Order link AND the closeout form link.
339
+ async function dispatchCrew(caseId,btn){
340
+ const inp=document.getElementById('crew-'+caseId);
341
+ const email=inp?inp.value.trim():'';
342
+ if(!email||email.indexOf('@')<1){ alert('Enter a valid crew email.'); if(inp)inp.focus(); return; }
343
+ const c=allCases.find(x=>x.caseId===caseId)||{caseId:caseId};
344
+ const meta={caseId:c.caseId,address:c.address,ward:c.ward,district:c.district,maintenanceZone:c.maintenanceZone,
345
+ latitude:c.latitude,longitude:c.longitude,severityScore:c.severityScore,severityScore0100:c.severityScore,
346
+ severityLevel:c.severityLevel,potholeSize:c.potholeSize,weatherRisk:c.weatherRisk,aiReviewStatus:c.aiReviewStatus,
347
+ reporterContact:c.reporterContact,photoFileId:c.photoFileId,submittedAt:c.submittedAt,assignedCrew:email};
348
+ const lbl=btn?btn.textContent:''; if(btn){btn.disabled=true;btn.textContent='Sending…';}
349
+ try{
350
+ // Box Sign: backend (service account) emails the crew a "review & sign" work order β€” no mailto, no personal info
351
+ const r=await fetch('/api/box/dispatch-crew',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authHeaders()),body:JSON.stringify({caseId,crewEmail:email,metadata:meta})}).then(x=>x.json());
352
+ if(!r.ok) throw new Error(r.error||r.reason||'dispatch failed');
353
+ if(btn){btn.textContent='βœ“ Sent via Box Sign';}
354
+ if(inp){inp.value='';inp.placeholder='Sent to '+email;}
355
+ }catch(e){ alert('Could not send to crew ('+caseId+'): '+e.message); if(btn){btn.disabled=false;btn.textContent=lbl;} }
356
+ }
357
+
358
+ let _charts={};
359
+ function renderCharts(cs){
360
+ if(typeof Chart==='undefined') return;
361
+ const BLUE='#0061d5';
362
+ const ward={}; cs.forEach(c=>{const w='Ward '+(c.ward||'?'); ward[w]=(ward[w]||0)+1;});
363
+ const we=Object.entries(ward).sort((a,b)=>b[1]-a[1]).slice(0,8);
364
+ const size={small:0,medium:0,large:0}; cs.forEach(c=>{const s=(c.potholeSize||'').toLowerCase(); if(s in size) size[s]++;});
365
+ const day={}; cs.forEach(c=>{if(c.submittedAt){const d=new Date(c.submittedAt); if(!isNaN(d)){const k=d.toISOString().slice(0,10); day[k]=(day[k]||0)+1;}}});
366
+ const days=Object.keys(day).sort();
367
+ const mk=(id,cfg)=>{ if(_charts[id])_charts[id].destroy(); const el=document.getElementById(id); if(el)_charts[id]=new Chart(el,cfg); };
368
+ const barOpts={responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{precision:0}}}};
369
+ mk('chWard',{type:'bar',data:{labels:we.map(e=>e[0]),datasets:[{data:we.map(e=>e[1]),backgroundColor:BLUE,borderRadius:5}]},options:barOpts});
370
+ mk('chSize',{type:'doughnut',data:{labels:['Small','Medium','Large'],datasets:[{data:[size.small,size.medium,size.large],backgroundColor:['#8bb8f0','#3b86e8','#0b3d91']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom'}}}});
371
+ mk('chTrend',{type:'line',data:{labels:days,datasets:[{data:days.map(d=>day[d]),borderColor:BLUE,backgroundColor:'rgba(0,97,213,.12)',fill:true,tension:.3,pointRadius:2}]},options:barOpts});
372
+ }
373
+
374
+ async function load(){
375
+ // keep the supervisor session token fresh so the auto-refresh fetch doesn't 401 (and flicker to 0)
376
+ try { const { data } = await sbAuth.auth.getSession(); if (data && data.session) ACCESS_TOKEN = data.session.access_token; } catch(_){}
377
+ const [casesRes,bRes]=await Promise.all([
378
+ fetch('/api/box/cases',{headers:authHeaders()}).then(r=>r.ok?r.json():null).catch(()=>null),
379
+ fetch('/api/gis/boundaries').then(r=>r.json()).catch(()=>null),
380
+ ]);
381
+ const cases = casesRes && Array.isArray(casesRes.cases) ? casesRes.cases : null;
382
+ // update on a good fetch; on a failed/empty refresh keep what we already have (don't blank the dashboard)
383
+ if (cases && (cases.length || !allCases.length)) allCases = cases;
384
+ renderKpis(allCases); renderDonut(allCases); renderPipe(allCases); renderMap(allCases,bRes);
385
+ renderCharts(allCases);
386
+ buildChips(); applyFilters();
387
+ }
388
+ (async()=>{ if (await requireSupervisor()){ load(); setInterval(load, 30000); } })(); // auto-refresh every 30s
389
+ </script>
390
+ </body>
391
+ </html>
config-store.js ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+
6
+ const CONFIG_FILE_NAME = 'config.json';
7
+
8
+ const DEFAULT_BOX_CONFIG = {
9
+ authMode: 'oauth_refresh',
10
+ accessToken: '',
11
+ accessTokenExpiresAt: '',
12
+ refreshToken: '',
13
+ clientId: '',
14
+ clientSecret: '',
15
+ subjectType: 'enterprise',
16
+ subjectId: '',
17
+ intakeFolderId: '',
18
+ caseRootFolderId: '',
19
+ metadataScope: '',
20
+ metadataTemplateKey: '',
21
+ updatedAt: '',
22
+ };
23
+
24
+ const DEFAULT_APP_CONFIG = {
25
+ box: { ...DEFAULT_BOX_CONFIG },
26
+ };
27
+
28
+ function nowIso() {
29
+ return new Date().toISOString();
30
+ }
31
+
32
+ function cloneDefaults() {
33
+ return JSON.parse(JSON.stringify(DEFAULT_APP_CONFIG));
34
+ }
35
+
36
+ function normalizeString(value) {
37
+ return String(value ?? '').trim();
38
+ }
39
+
40
+ function maskSecret(secret) {
41
+ const normalized = normalizeString(secret);
42
+ if (!normalized) return '';
43
+ if (normalized.length <= 8) return `${normalized.slice(0, 2)}...${normalized.slice(-2)}`;
44
+ return `${normalized.slice(0, 4)}...${normalized.slice(-4)}`;
45
+ }
46
+
47
+ function sanitizeAuthMode(value, fallback = DEFAULT_BOX_CONFIG.authMode) {
48
+ const normalized = normalizeString(value).toLowerCase();
49
+ if (['access_token', 'oauth_refresh', 'client_credentials'].includes(normalized)) {
50
+ return normalized;
51
+ }
52
+ return fallback;
53
+ }
54
+
55
+ function sanitizeSubjectType(value, fallback = DEFAULT_BOX_CONFIG.subjectType) {
56
+ const normalized = normalizeString(value).toLowerCase();
57
+ if (normalized === 'user' || normalized === 'enterprise') {
58
+ return normalized;
59
+ }
60
+ return fallback;
61
+ }
62
+
63
+ function mergeBoxConfig(existing = {}, incoming = {}, options = {}) {
64
+ const next = {
65
+ ...DEFAULT_BOX_CONFIG,
66
+ ...(existing || {}),
67
+ };
68
+
69
+ if (options.reset) {
70
+ return {
71
+ ...DEFAULT_BOX_CONFIG,
72
+ updatedAt: nowIso(),
73
+ };
74
+ }
75
+
76
+ if (Object.prototype.hasOwnProperty.call(incoming, 'authMode')) {
77
+ next.authMode = sanitizeAuthMode(incoming.authMode, next.authMode);
78
+ }
79
+
80
+ if (Object.prototype.hasOwnProperty.call(incoming, 'subjectType')) {
81
+ next.subjectType = sanitizeSubjectType(incoming.subjectType, next.subjectType);
82
+ }
83
+
84
+ const copyFields = [
85
+ 'clientId',
86
+ 'subjectId',
87
+ 'intakeFolderId',
88
+ 'caseRootFolderId',
89
+ 'metadataScope',
90
+ 'metadataTemplateKey',
91
+ ];
92
+
93
+ for (const field of copyFields) {
94
+ if (Object.prototype.hasOwnProperty.call(incoming, field)) {
95
+ next[field] = normalizeString(incoming[field]);
96
+ }
97
+ }
98
+
99
+ if (Object.prototype.hasOwnProperty.call(incoming, 'accessTokenExpiresAt')) {
100
+ next.accessTokenExpiresAt = normalizeString(incoming.accessTokenExpiresAt);
101
+ }
102
+
103
+ const secretFieldMap = [
104
+ ['accessToken', 'clearAccessToken'],
105
+ ['refreshToken', 'clearRefreshToken'],
106
+ ['clientSecret', 'clearClientSecret'],
107
+ ];
108
+
109
+ for (const [field, clearField] of secretFieldMap) {
110
+ if (incoming[clearField]) {
111
+ next[field] = '';
112
+ if (field === 'accessToken') next.accessTokenExpiresAt = '';
113
+ continue;
114
+ }
115
+
116
+ if (!Object.prototype.hasOwnProperty.call(incoming, field)) continue;
117
+ const normalized = normalizeString(incoming[field]);
118
+ if (normalized) {
119
+ next[field] = normalized;
120
+ if (field === 'accessToken' && !incoming.accessTokenExpiresAt) {
121
+ next.accessTokenExpiresAt = '';
122
+ }
123
+ }
124
+ }
125
+
126
+ next.updatedAt = nowIso();
127
+ return next;
128
+ }
129
+
130
+ function sanitizeBoxConfigForClient(box = {}) {
131
+ const merged = { ...DEFAULT_BOX_CONFIG, ...(box || {}) };
132
+ return {
133
+ authMode: sanitizeAuthMode(merged.authMode),
134
+ clientId: normalizeString(merged.clientId),
135
+ subjectType: sanitizeSubjectType(merged.subjectType),
136
+ subjectId: normalizeString(merged.subjectId),
137
+ intakeFolderId: normalizeString(merged.intakeFolderId),
138
+ caseRootFolderId: normalizeString(merged.caseRootFolderId),
139
+ metadataScope: normalizeString(merged.metadataScope),
140
+ metadataTemplateKey: normalizeString(merged.metadataTemplateKey),
141
+ accessTokenPreview: maskSecret(merged.accessToken),
142
+ refreshTokenPreview: maskSecret(merged.refreshToken),
143
+ clientSecretPreview: maskSecret(merged.clientSecret),
144
+ hasAccessToken: !!normalizeString(merged.accessToken),
145
+ hasRefreshToken: !!normalizeString(merged.refreshToken),
146
+ hasClientSecret: !!normalizeString(merged.clientSecret),
147
+ accessTokenExpiresAt: normalizeString(merged.accessTokenExpiresAt),
148
+ updatedAt: normalizeString(merged.updatedAt),
149
+ };
150
+ }
151
+
152
+ function createConfigStore({ dataDir }) {
153
+ const configPath = path.join(dataDir, CONFIG_FILE_NAME);
154
+ let writeQueue = Promise.resolve();
155
+
156
+ async function ensureConfigFile() {
157
+ await fs.mkdir(dataDir, { recursive: true });
158
+ try {
159
+ await fs.access(configPath);
160
+ } catch {
161
+ await fs.writeFile(configPath, JSON.stringify(cloneDefaults(), null, 2), 'utf8');
162
+ }
163
+ }
164
+
165
+ async function readConfig() {
166
+ await ensureConfigFile();
167
+ const raw = await fs.readFile(configPath, 'utf8');
168
+ const parsed = JSON.parse(raw);
169
+ return {
170
+ ...cloneDefaults(),
171
+ ...(parsed || {}),
172
+ box: {
173
+ ...DEFAULT_BOX_CONFIG,
174
+ ...((parsed && parsed.box) || {}),
175
+ },
176
+ };
177
+ }
178
+
179
+ async function writeConfig(nextConfig) {
180
+ const normalized = {
181
+ ...cloneDefaults(),
182
+ ...(nextConfig || {}),
183
+ box: {
184
+ ...DEFAULT_BOX_CONFIG,
185
+ ...((nextConfig && nextConfig.box) || {}),
186
+ },
187
+ };
188
+ const tmpPath = `${configPath}.tmp`;
189
+ await fs.writeFile(tmpPath, JSON.stringify(normalized, null, 2), 'utf8');
190
+ await fs.rename(tmpPath, configPath);
191
+ return normalized;
192
+ }
193
+
194
+ async function queueMutation(mutator) {
195
+ const task = writeQueue.then(async () => {
196
+ const current = await readConfig();
197
+ const next = await mutator(current);
198
+ const written = await writeConfig(next || current);
199
+ return written;
200
+ });
201
+ writeQueue = task.catch(() => undefined);
202
+ return task;
203
+ }
204
+
205
+ return {
206
+ configPath,
207
+ readConfig,
208
+ writeConfig,
209
+ queueMutation,
210
+ mergeBoxConfig,
211
+ sanitizeBoxConfigForClient,
212
+ defaults: {
213
+ box: { ...DEFAULT_BOX_CONFIG },
214
+ },
215
+ };
216
+ }
217
+
218
+ module.exports = {
219
+ createConfigStore,
220
+ mergeBoxConfig,
221
+ sanitizeBoxConfigForClient,
222
+ DEFAULT_BOX_CONFIG,
223
+ };
crew-closeout.html ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Crew Closeout β€” PotholeIQ</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="styles.css?v=20260604a" />
11
+ <style>
12
+ body { padding-top: 64px; }
13
+ .closeout-shell {
14
+ min-height: calc(100vh - 64px);
15
+ background: linear-gradient(180deg, #F7F9FC 0%, #ECF2FB 100%);
16
+ padding: 36px 20px 72px;
17
+ }
18
+ .closeout-wrap {
19
+ max-width: 1100px;
20
+ margin: 0 auto;
21
+ display: grid;
22
+ grid-template-columns: 320px minmax(0, 1fr);
23
+ gap: 24px;
24
+ align-items: start;
25
+ }
26
+ .closeout-panel {
27
+ background: #fff;
28
+ border: 1px solid #E4E9F0;
29
+ border-radius: 18px;
30
+ box-shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
31
+ }
32
+ .closeout-panel__head {
33
+ padding: 22px 24px 18px;
34
+ border-bottom: 1px solid #E8EDF5;
35
+ }
36
+ .closeout-panel__title {
37
+ font-size: 20px;
38
+ font-weight: 800;
39
+ color: #162038;
40
+ margin-bottom: 6px;
41
+ }
42
+ .closeout-panel__sub {
43
+ font-size: 13px;
44
+ color: #6B7280;
45
+ line-height: 1.6;
46
+ }
47
+ .closeout-panel__body {
48
+ padding: 22px 24px 24px;
49
+ }
50
+ .detail-stack {
51
+ display: grid;
52
+ gap: 12px;
53
+ }
54
+ .detail-card {
55
+ border: 1px solid #E7ECF4;
56
+ border-radius: 14px;
57
+ padding: 14px 16px;
58
+ background: #FAFCFF;
59
+ }
60
+ .detail-card__label {
61
+ font-size: 11px;
62
+ font-weight: 700;
63
+ text-transform: uppercase;
64
+ letter-spacing: 0.06em;
65
+ color: #7A8396;
66
+ margin-bottom: 4px;
67
+ }
68
+ .detail-card__value {
69
+ font-size: 14px;
70
+ font-weight: 600;
71
+ color: #1A2340;
72
+ line-height: 1.5;
73
+ word-break: break-word;
74
+ }
75
+ .closeout-form {
76
+ display: grid;
77
+ gap: 18px;
78
+ }
79
+ .form-grid {
80
+ display: grid;
81
+ grid-template-columns: repeat(2, minmax(0, 1fr));
82
+ gap: 16px;
83
+ }
84
+ .field-group {
85
+ display: grid;
86
+ gap: 8px;
87
+ }
88
+ .field-group label {
89
+ font-size: 12px;
90
+ font-weight: 700;
91
+ color: #46506A;
92
+ text-transform: uppercase;
93
+ letter-spacing: 0.05em;
94
+ }
95
+ .field-input,
96
+ .field-textarea,
97
+ .field-select {
98
+ width: 100%;
99
+ border: 1.5px solid #D8E0ED;
100
+ border-radius: 12px;
101
+ background: #fff;
102
+ color: #122033;
103
+ font: inherit;
104
+ padding: 12px 14px;
105
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
106
+ }
107
+ .field-textarea { min-height: 130px; resize: vertical; }
108
+ .field-input:focus,
109
+ .field-textarea:focus,
110
+ .field-select:focus {
111
+ outline: none;
112
+ border-color: #0061D5;
113
+ box-shadow: 0 0 0 3px rgba(0, 97, 213, 0.12);
114
+ }
115
+ .field-hint {
116
+ font-size: 12px;
117
+ color: #768195;
118
+ line-height: 1.5;
119
+ }
120
+ .cost-total-box {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: space-between;
124
+ gap: 16px;
125
+ padding: 16px 18px;
126
+ border-radius: 14px;
127
+ border: 1.5px solid #FDE2A7;
128
+ background: linear-gradient(135deg, #FFFBF0, #FFF6E0);
129
+ }
130
+ .cost-total-box__label {
131
+ font-size: 12px;
132
+ font-weight: 700;
133
+ text-transform: uppercase;
134
+ letter-spacing: 0.05em;
135
+ color: #9A6608;
136
+ }
137
+ .cost-total-box__hint {
138
+ font-size: 12px;
139
+ color: #B07B2A;
140
+ margin-top: 3px;
141
+ }
142
+ .cost-total-box__amount {
143
+ font-size: 26px;
144
+ font-weight: 800;
145
+ letter-spacing: -0.02em;
146
+ color: #92500C;
147
+ font-variant-numeric: tabular-nums;
148
+ white-space: nowrap;
149
+ }
150
+ .status-banner {
151
+ display: none;
152
+ margin-top: 18px;
153
+ padding: 14px 16px;
154
+ border-radius: 14px;
155
+ font-size: 14px;
156
+ font-weight: 600;
157
+ line-height: 1.6;
158
+ }
159
+ .status-banner.ok {
160
+ display: block;
161
+ background: #EAFAF2;
162
+ border: 1px solid #BFE8D1;
163
+ color: #166C43;
164
+ }
165
+ .status-banner.err {
166
+ display: block;
167
+ background: #FEF2F2;
168
+ border: 1px solid #F6C5C5;
169
+ color: #A12828;
170
+ }
171
+ .action-row {
172
+ display: flex;
173
+ gap: 12px;
174
+ flex-wrap: wrap;
175
+ align-items: center;
176
+ }
177
+ .btn-solid {
178
+ border: none;
179
+ border-radius: 12px;
180
+ padding: 12px 18px;
181
+ font-size: 14px;
182
+ font-weight: 700;
183
+ cursor: pointer;
184
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
185
+ }
186
+ .btn-solid.primary {
187
+ background: #0061D5;
188
+ color: #fff;
189
+ box-shadow: 0 10px 24px rgba(0, 97, 213, 0.2);
190
+ }
191
+ .btn-solid.secondary {
192
+ background: #EEF3FB;
193
+ color: #24436B;
194
+ }
195
+ .btn-solid:hover { transform: translateY(-1px); }
196
+ @media (max-width: 900px) {
197
+ .closeout-wrap { grid-template-columns: 1fr; }
198
+ .form-grid { grid-template-columns: 1fr; }
199
+ }
200
+ </style>
201
+ </head>
202
+ <body>
203
+ <nav class="nav-header">
204
+ <div class="nav-header__inner">
205
+ <div class="nav-header__logo">
206
+ <div class="nav-header__logo-icon">P</div>
207
+ <span>PotholeIQ β€” Crew Closeout</span>
208
+ </div>
209
+ </div>
210
+ </nav>
211
+
212
+ <main class="closeout-shell">
213
+ <div class="closeout-wrap">
214
+ <section class="closeout-panel">
215
+ <div class="closeout-panel__head">
216
+ <div class="closeout-panel__title">Assigned Case Snapshot</div>
217
+ <div class="closeout-panel__sub">Select an assigned case, verify the location and severity context, then submit the field proof and closeout details.</div>
218
+ </div>
219
+ <div class="closeout-panel__body">
220
+ <div class="field-group" style="margin-bottom:18px;">
221
+ <label for="closeout-case">Case</label>
222
+ <select id="closeout-case" class="field-select"></select>
223
+ </div>
224
+ <div class="detail-stack" id="case-detail-stack">
225
+ <div class="detail-card">
226
+ <div class="detail-card__label">Status</div>
227
+ <div class="detail-card__value">No case selected yet.</div>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </section>
232
+
233
+ <section class="closeout-panel">
234
+ <div class="closeout-panel__head">
235
+ <div class="closeout-panel__title">Crew Completion Form</div>
236
+ <div class="closeout-panel__sub">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.</div>
237
+ </div>
238
+ <div class="closeout-panel__body">
239
+ <form class="closeout-form" id="closeout-form">
240
+ <div class="form-grid">
241
+ <div class="field-group">
242
+ <label for="crew-name">Crew Name</label>
243
+ <input id="crew-name" class="field-input" type="text" placeholder="Crew Alpha / Crew 2" />
244
+ </div>
245
+ <div class="field-group">
246
+ <label for="repair-method">Repair Method</label>
247
+ <select id="repair-method" class="field-select">
248
+ <option value="Cold Patch">Cold Patch</option>
249
+ <option value="Hot Mix">Hot Mix</option>
250
+ <option value="Full Depth Repair">Full Depth Repair</option>
251
+ <option value="Temporary Safety Patch">Temporary Safety Patch</option>
252
+ </select>
253
+ </div>
254
+ </div>
255
+
256
+ <div class="form-grid">
257
+ <div class="field-group">
258
+ <label for="materials-used">Materials Used</label>
259
+ <input id="materials-used" class="field-input" type="text" placeholder="2 cold-mix bags, 1 sealant can" />
260
+ </div>
261
+ <div class="field-group">
262
+ <label for="labor-hours">Labor Hours</label>
263
+ <input id="labor-hours" class="field-input" type="number" min="0" step="0.25" placeholder="2.5" />
264
+ </div>
265
+ </div>
266
+
267
+ <div class="form-grid">
268
+ <div class="field-group">
269
+ <label for="labor-rate">Labor Rate ($/hr)</label>
270
+ <input id="labor-rate" class="field-input" type="number" min="0" step="0.01" placeholder="45.00" value="45" />
271
+ </div>
272
+ <div class="field-group">
273
+ <label for="materials-cost">Materials Cost ($)</label>
274
+ <input id="materials-cost" class="field-input" type="number" min="0" step="0.01" placeholder="120.00" />
275
+ </div>
276
+ </div>
277
+
278
+ <div class="cost-total-box">
279
+ <div>
280
+ <div class="cost-total-box__label">Total Spend (labor + materials)</div>
281
+ <div class="cost-total-box__hint" id="cost-breakdown-hint">Enter labor hours, rate, and materials cost.</div>
282
+ </div>
283
+ <div class="cost-total-box__amount" id="total-spend-display">$0.00</div>
284
+ </div>
285
+
286
+ <div class="field-group">
287
+ <label for="resolution-notes">Resolution Notes</label>
288
+ <textarea id="resolution-notes" class="field-textarea" placeholder="Describe the repair, safety observations, and anything the supervisor should know."></textarea>
289
+ </div>
290
+
291
+ <div class="form-grid">
292
+ <div class="field-group">
293
+ <label for="crew-signature">Crew Signature</label>
294
+ <input id="crew-signature" class="field-input" type="text" placeholder="Crew lead full name / badge" />
295
+ </div>
296
+ <div class="field-group">
297
+ <label for="after-photo">After Photo</label>
298
+ <input id="after-photo" class="field-input" type="file" accept="image/*" />
299
+ <div class="field-hint">This uploads directly into the case folder in Box before the closeout record is saved.</div>
300
+ </div>
301
+ </div>
302
+
303
+ <div class="field-group">
304
+ <label for="notify-email">Also Email Report To <span style="font-weight:500;text-transform:none;color:#768195;">β€” optional, comma-separated</span></label>
305
+ <input id="notify-email" class="field-input" type="text" placeholder="extra.person@city.gov, another@city.gov" autocomplete="off" />
306
+ <div class="field-hint">Every closeout already shares the report with the <strong>default recipients</strong> configured for the team (via Box). Add any extra one-off recipients here, separated by commas. <strong>One-time invite:</strong> each person is invited once to the Box <strong>β€œCloseout Reports”</strong> 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.</div>
307
+ </div>
308
+
309
+ <div class="action-row">
310
+ <button type="submit" class="btn-solid primary" id="submit-closeout">Submit Closeout & Resolve Case</button>
311
+ </div>
312
+ </form>
313
+
314
+ <div id="closeout-status" class="status-banner"></div>
315
+ </div>
316
+ </section>
317
+ </div>
318
+ </main>
319
+
320
+ <script src="backend-api.js?v=20260604a"></script>
321
+ <script>
322
+ 'use strict';
323
+
324
+ const CASES_KEY = 'submitted_cases';
325
+
326
+ function $(id) {
327
+ return document.getElementById(id);
328
+ }
329
+
330
+ function readLocalCases() {
331
+ try {
332
+ return JSON.parse(localStorage.getItem(CASES_KEY) || '[]');
333
+ } catch {
334
+ return [];
335
+ }
336
+ }
337
+
338
+ function writeLocalCases(cases) {
339
+ localStorage.setItem(CASES_KEY, JSON.stringify(Array.isArray(cases) ? cases : []));
340
+ }
341
+
342
+ function caseDetailField(label, value) {
343
+ return `
344
+ <div class="detail-card">
345
+ <div class="detail-card__label">${label}</div>
346
+ <div class="detail-card__value">${value || 'β€”'}</div>
347
+ </div>`;
348
+ }
349
+
350
+ function eligibleCloseoutCases(cases) {
351
+ return (Array.isArray(cases) ? cases : [])
352
+ .filter((item) => item && item.caseId)
353
+ .filter((item) => !['Resolved', 'Rejected'].includes(String(item.status || '')))
354
+ .filter((item) => item.report?.supervisor?.isPothole !== false);
355
+ }
356
+
357
+ function renderCaseOptions(cases, selectedCaseId = '') {
358
+ const options = ['<option value="">Select an assigned case</option>'];
359
+ cases.forEach((item) => {
360
+ const selected = item.caseId === selectedCaseId ? ' selected' : '';
361
+ options.push(`<option value="${item.caseId}"${selected}>${item.caseId} β€” ${item.report?.address || item.report?.areaLabel || 'Unknown address'}</option>`);
362
+ });
363
+ $('closeout-case').innerHTML = options.join('');
364
+ }
365
+
366
+ function updateCaseSummary(caseRecord) {
367
+ if (!caseRecord) {
368
+ $('case-detail-stack').innerHTML = caseDetailField('Status', 'No case selected yet.');
369
+ return;
370
+ }
371
+
372
+ const report = caseRecord.report || {};
373
+ $('case-detail-stack').innerHTML = [
374
+ caseDetailField('Status', caseRecord.status || 'Unknown'),
375
+ caseDetailField('Address', report.address || 'Unknown'),
376
+ caseDetailField('Ward / District', `${report.ward || 'Pending'} / ${report.district || 'Pending'}`),
377
+ caseDetailField('Maintenance Zone', report.maintenanceZone || 'Pending'),
378
+ caseDetailField('Severity', `${report.enrichment?.severityLevel || report.workflowSeverityLevel || 'Pending'} (${report.enrichment?.severityScore ?? report.workflowSeverityScore ?? 'N/A'})`),
379
+ caseDetailField('Assigned Crew', report.dispatchPlan?.crewName || report.crewName || report.assignedTo || 'Pending'),
380
+ ].join('');
381
+
382
+ $('crew-name').value = report.dispatchPlan?.crewName || report.crewName || report.assignedTo || '';
383
+ $('repair-method').value = report.repairMethod || 'Cold Patch';
384
+ $('materials-used').value = report.materialsUsed || '';
385
+ $('labor-hours').value = report.laborHours || '';
386
+ $('labor-rate').value = report.laborRate || '45';
387
+ $('materials-cost').value = report.materialsCost || '';
388
+ $('resolution-notes').value = report.resolutionNotes || '';
389
+ $('crew-signature').value = report.crewSignature || '';
390
+ recomputeSpend();
391
+ }
392
+
393
+ function setStatus(message, tone = 'ok') {
394
+ const el = $('closeout-status');
395
+ el.className = `status-banner ${tone}`;
396
+ el.textContent = message;
397
+ }
398
+
399
+ function computeSpend() {
400
+ const hours = parseFloat($('labor-hours').value) || 0;
401
+ const rate = parseFloat($('labor-rate').value) || 0;
402
+ const materials = parseFloat($('materials-cost').value) || 0;
403
+ const labor = hours * rate;
404
+ return { hours, rate, materials, labor, total: labor + materials };
405
+ }
406
+
407
+ function recomputeSpend() {
408
+ const s = computeSpend();
409
+ $('total-spend-display').textContent = `$${s.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
410
+ $('cost-breakdown-hint').textContent = s.total > 0
411
+ ? `Labor ${s.hours}h Γ— $${s.rate.toFixed(2)} = $${s.labor.toFixed(2)} Β· Materials $${s.materials.toFixed(2)}`
412
+ : 'Enter labor hours, rate, and materials cost.';
413
+ }
414
+
415
+ // Map a Box case (flat, from /api/box/cases) to the { caseId, status, report } shape.
416
+ function boxCaseToRecord(c) {
417
+ return {
418
+ caseId: c.caseId,
419
+ status: c.status || 'Assigned',
420
+ ts: c.submittedAt || Date.now(),
421
+ report: {
422
+ address: c.address || '', ward: c.ward || '', district: c.district || '',
423
+ maintenanceZone: c.maintenanceZone || '',
424
+ location: (c.latitude != null && c.longitude != null) ? { lat: c.latitude, lng: c.longitude } : null,
425
+ photoFileId: c.photoFileId || '', aiResult: c.potholeSize || '', potholeSize: c.potholeSize || '',
426
+ reporterContact: c.reporterContact || '', submittedAt: c.submittedAt || '',
427
+ enrichment: { severityScore: c.severityScore, severityLevel: c.severityLevel, severityBreakdown: c.breakdown || {} },
428
+ },
429
+ };
430
+ }
431
+
432
+ async function fetchBoxCases() {
433
+ try {
434
+ const r = await fetch('/api/box/cases').then((x) => x.json());
435
+ return Array.isArray(r.cases) ? r.cases.map(boxCaseToRecord) : [];
436
+ } catch (_) { return []; }
437
+ }
438
+
439
+ // Load every case (local/DB + Box) so the form can open to any case via ?case=.
440
+ let CASES_BY_ID = {};
441
+ async function loadCases() {
442
+ let remoteCases = [];
443
+ try {
444
+ remoteCases = await window.PotholeBackend.getCases();
445
+ } catch (error) {
446
+ console.warn('[CrewCloseout] remote case load failed:', error);
447
+ }
448
+ const merged = window.PotholeBackend.mergeCaseLists(readLocalCases(), remoteCases);
449
+ const have = new Set(merged.map((c) => c.caseId));
450
+ (await fetchBoxCases()).forEach((bc) => { if (!have.has(bc.caseId)) merged.push(bc); });
451
+ CASES_BY_ID = Object.fromEntries(merged.map((c) => [c.caseId, c]));
452
+ return merged;
453
+ }
454
+
455
+ async function handleSubmit(event) {
456
+ event.preventDefault();
457
+ const caseId = $('closeout-case').value;
458
+ if (!caseId) {
459
+ setStatus('Select an assigned case before submitting closeout.', 'err');
460
+ return;
461
+ }
462
+
463
+ const crewName = $('crew-name').value.trim();
464
+ const resolutionNotes = $('resolution-notes').value.trim();
465
+ const crewSignature = $('crew-signature').value.trim();
466
+ if (!crewName || !resolutionNotes || !crewSignature) {
467
+ setStatus('Crew name, resolution notes, and crew signature are required.', 'err');
468
+ return;
469
+ }
470
+
471
+ const submitBtn = $('submit-closeout');
472
+ submitBtn.disabled = true;
473
+ submitBtn.textContent = 'Submitting closeout…';
474
+
475
+ try {
476
+ // Ensure the case exists in the backend store (Box-only cases aren't there yet),
477
+ // so the after-photo upload + closeout can find it. The server backfills the rest.
478
+ const selected = CASES_BY_ID[caseId];
479
+ if (selected) {
480
+ await window.PotholeBackend.upsertCase({
481
+ caseId,
482
+ status: selected.status || 'Assigned',
483
+ report: selected.report || {},
484
+ }).catch(() => {});
485
+ }
486
+
487
+ let afterPhotoUpload = null;
488
+ const photoFile = $('after-photo').files && $('after-photo').files[0];
489
+ if (photoFile) {
490
+ const ext = (photoFile.name.split('.').pop() || 'jpg').toLowerCase();
491
+ const safeName = `${caseId}_after_photo.${ext}`;
492
+ afterPhotoUpload = await window.PotholeBackend.uploadCaseFile(caseId, safeName, photoFile, photoFile.type || 'image/jpeg');
493
+ }
494
+
495
+ const spend = computeSpend();
496
+ const notifyEmail = $('notify-email').value.trim();
497
+ if (notifyEmail) localStorage.setItem('closeout_notify_email', notifyEmail);
498
+ const response = await window.PotholeBackend.submitCrewCloseout(caseId, {
499
+ crewName,
500
+ repairMethod: $('repair-method').value,
501
+ materialsUsed: $('materials-used').value.trim(),
502
+ laborHours: $('labor-hours').value.trim(),
503
+ laborRate: $('labor-rate').value.trim(),
504
+ materialsCost: $('materials-cost').value.trim(),
505
+ totalCost: spend.total.toFixed(2),
506
+ notifyEmail,
507
+ resolutionNotes,
508
+ crewSignature,
509
+ afterPhotoFileId: afterPhotoUpload?.fileId || '',
510
+ afterPhotoFileName: afterPhotoUpload?.fileName || '',
511
+ });
512
+
513
+ const updatedCase = response?.case;
514
+ if (!updatedCase) {
515
+ throw new Error('Closeout did not return an updated case.');
516
+ }
517
+
518
+ const allCases = window.PotholeBackend.mergeCaseLists(readLocalCases(), [updatedCase]);
519
+ writeLocalCases(allCases);
520
+ renderCaseOptions(eligibleCloseoutCases(allCases));
521
+ updateCaseSummary(updatedCase);
522
+ $('closeout-form').reset();
523
+ $('closeout-case').value = '';
524
+
525
+ // Summarize the Box notification outcome (one line per recipient).
526
+ const notifications = response?.notifications || [];
527
+ const sharedLink = response?.sharedLink || '';
528
+ const boxLink = sharedLink
529
+ ? `<div style="margin-top:6px;"><a href="${sharedLink}" target="_blank" style="color:#0061D5;font-weight:700;">Open report in Box β†’</a></div>`
530
+ : '';
531
+ let notifyLine = '';
532
+ if (notifications.length) {
533
+ const items = notifications.map((n) => {
534
+ if (n.firstTime) return `πŸ“§ <strong>${n.to}</strong> β€” invited to the Box reports workspace (one-time)`;
535
+ if (n.emailed) return `πŸ“§ <strong>${n.to}</strong> β€” report emailed`;
536
+ if (n.alreadyHasAccess) return `πŸ“ <strong>${n.to}</strong> β€” already has access (no new invite)`;
537
+ if (n.reason === 'box_not_configured') return `⚠️ <strong>${n.to}</strong> β€” Box not connected, not sent`;
538
+ if (n.error) return `⚠️ <strong>${n.to}</strong> β€” ${n.error}`;
539
+ return `β€’ <strong>${n.to}</strong>`;
540
+ });
541
+ notifyLine = `<div style="margin-top:10px;font-weight:700;">Report shared via Box with ${notifications.length} recipient${notifications.length === 1 ? '' : 's'}:</div>
542
+ <ul style="margin:4px 0 0 18px;padding:0;font-weight:500;">${items.map((i) => `<li style="margin:2px 0;">${i}</li>`).join('')}</ul>${boxLink}`;
543
+ }
544
+
545
+ const statusEl = $('closeout-status');
546
+ statusEl.className = 'status-banner ok';
547
+ statusEl.innerHTML = `Closeout submitted for <strong>${caseId}</strong>. The before/after report and audit artifacts have been generated${afterPhotoUpload?.fileId ? ' and the after-photo is stored in Box' : ''}.${notifyLine}
548
+ <a href="case-report.html?case=${encodeURIComponent(caseId)}" target="_blank" style="display:inline-block;margin-top:10px;padding:9px 16px;border-radius:10px;background:#0061D5;color:#fff;text-decoration:none;font-weight:700;">View Before / After Report β†’</a>`;
549
+ } catch (error) {
550
+ console.error('[CrewCloseout] submit failed:', error);
551
+ setStatus(`Could not submit closeout: ${error.message}`, 'err');
552
+ } finally {
553
+ submitBtn.disabled = false;
554
+ submitBtn.textContent = 'Submit Closeout & Resolve Case';
555
+ }
556
+ }
557
+
558
+ async function init() {
559
+ const allCases = await loadCases();
560
+ const requestedCaseId = new URLSearchParams(window.location.search).get('case') || '';
561
+
562
+ if (requestedCaseId) {
563
+ // Locked mode: this form is for exactly the pothole that was sent to the crew.
564
+ // Show only that case β€” no picker, no defaulting to some other case.
565
+ const theCase = CASES_BY_ID[requestedCaseId];
566
+ if (!theCase) {
567
+ renderCaseOptions([], '');
568
+ $('closeout-case').disabled = true;
569
+ $('case-detail-stack').innerHTML = caseDetailField('Status', `Case "${requestedCaseId}" was not found.`);
570
+ $('submit-closeout').disabled = true;
571
+ } else {
572
+ renderCaseOptions([theCase], requestedCaseId);
573
+ $('closeout-case').value = requestedCaseId;
574
+ $('closeout-case').disabled = true; // crew can't switch to another case
575
+ updateCaseSummary(theCase);
576
+ }
577
+ } else {
578
+ // No case in the link: don't auto-pick one. Prompt to open from a specific case.
579
+ const eligible = eligibleCloseoutCases(allCases);
580
+ renderCaseOptions(eligible, '');
581
+ updateCaseSummary(null);
582
+ $('closeout-case').addEventListener('change', (event) => {
583
+ updateCaseSummary(CASES_BY_ID[event.target.value] || null);
584
+ });
585
+ }
586
+
587
+ ['labor-hours', 'labor-rate', 'materials-cost'].forEach((id) => {
588
+ $(id).addEventListener('input', recomputeSpend);
589
+ });
590
+ recomputeSpend();
591
+ $('notify-email').value = localStorage.getItem('closeout_notify_email') || '';
592
+
593
+ $('closeout-form').addEventListener('submit', handleSubmit);
594
+ }
595
+
596
+ init().catch((error) => {
597
+ console.error('[CrewCloseout] init failed:', error);
598
+ setStatus(`Could not initialize crew closeout: ${error.message}`, 'err');
599
+ });
600
+ </script>
601
+ </body>
602
+ </html>
dashcam-demo.html ADDED
@@ -0,0 +1,931 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
6
+ <title>Dashcam Capture Mode - City Road Reporting</title>
7
+ <meta name="description" content="Real-time pothole detection while driving. Windshield mount your device for hands-free scanning." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
12
+
13
+ <style>
14
+ /* ── Reset & Variables ────────────────────────────────────────────────── */
15
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
16
+
17
+ :root {
18
+ --bg-dark: #07090e;
19
+ --card-glass: rgba(13, 18, 30, 0.72);
20
+ --card-border: rgba(255, 255, 255, 0.08);
21
+ --accent-glow: rgba(0, 97, 213, 0.3);
22
+ --text-main: #f3f4f6;
23
+ --text-muted: #9ca3af;
24
+ --primary: #0061d5;
25
+
26
+ --color-small: #10b981;
27
+ --color-medium: #f59e0b;
28
+ --color-large: #ef4444;
29
+ --color-not-pothole: #6b7280;
30
+ }
31
+
32
+ body {
33
+ font-family: 'Inter', sans-serif;
34
+ background-color: var(--bg-dark);
35
+ color: var(--text-main);
36
+ overflow: hidden;
37
+ height: 100vh;
38
+ width: 100vw;
39
+ user-select: none;
40
+ -webkit-user-select: none;
41
+ }
42
+
43
+ /* ── Fullscreen Camera ────────────────────────────────────────────────── */
44
+ .camera-container {
45
+ position: absolute;
46
+ inset: 0;
47
+ z-index: 1;
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ background: #000;
52
+ }
53
+
54
+ #webcam-feed {
55
+ width: 100%;
56
+ height: 100%;
57
+ object-fit: cover;
58
+ transform: scaleX(1); /* Keep natural camera orientation */
59
+ }
60
+
61
+ /* ── Live Bounding-Box Detection Overlay ──────────────────────────────── */
62
+ #detection-overlay {
63
+ position: absolute;
64
+ inset: 0;
65
+ z-index: 3; /* above camera + flash, below HUD panels */
66
+ width: 100%;
67
+ height: 100%;
68
+ pointer-events: none;
69
+ }
70
+
71
+ /* ── Flash & Warning Vignette Overlay ─────────────────────────────────── */
72
+ .alert-overlay {
73
+ position: absolute;
74
+ inset: 0;
75
+ z-index: 2;
76
+ pointer-events: none;
77
+ box-shadow: inset 0 0 0px 0px rgba(239, 68, 68, 0);
78
+ transition: box-shadow 0.15s ease-out;
79
+ }
80
+
81
+ .alert-overlay.flash {
82
+ box-shadow: inset 0 0 80px 30px rgba(239, 68, 68, 0.65);
83
+ animation: alert-pulse 0.4s ease-out;
84
+ }
85
+
86
+ @keyframes alert-pulse {
87
+ 0% { box-shadow: inset 0 0 80px 40px rgba(239, 68, 68, 0.85); }
88
+ 100% { box-shadow: inset 0 0 0px 0px rgba(239, 68, 68, 0); }
89
+ }
90
+
91
+ /* ── HUD Dashboard (Overlays) ────────────────────────────────────────── */
92
+ .hud-layer {
93
+ position: absolute;
94
+ inset: 0;
95
+ z-index: 10;
96
+ pointer-events: none;
97
+ display: flex;
98
+ flex-direction: column;
99
+ justify-content: space-between;
100
+ padding: 16px;
101
+ }
102
+
103
+ /* Make items inside HUD layer clickable */
104
+ .hud-layer * {
105
+ pointer-events: auto;
106
+ }
107
+
108
+ /* Glassmorphism utility */
109
+ .glass-panel {
110
+ background: var(--card-glass);
111
+ backdrop-filter: blur(12px);
112
+ -webkit-backdrop-filter: blur(12px);
113
+ border: 1px solid var(--card-border);
114
+ border-radius: 12px;
115
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
116
+ }
117
+
118
+ /* ── Header HUD ───────────────────────────────────────────────────────── */
119
+ .hud-header {
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: space-between;
123
+ width: 100%;
124
+ margin-bottom: 8px;
125
+ }
126
+
127
+ .header-left {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 12px;
131
+ }
132
+
133
+ .btn-back {
134
+ width: 40px;
135
+ height: 40px;
136
+ border-radius: 50%;
137
+ border: 1px solid var(--card-border);
138
+ background: rgba(13, 18, 30, 0.6);
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ cursor: pointer;
143
+ color: var(--text-main);
144
+ transition: background 0.2s;
145
+ }
146
+
147
+ .btn-back:hover {
148
+ background: var(--primary);
149
+ }
150
+
151
+ .hud-title-wrap {
152
+ display: flex;
153
+ flex-direction: column;
154
+ }
155
+
156
+ .hud-title {
157
+ font-size: 15px;
158
+ font-weight: 800;
159
+ letter-spacing: 0.08em;
160
+ text-transform: uppercase;
161
+ color: var(--text-main);
162
+ text-shadow: 0 2px 4px rgba(0,0,0,0.5);
163
+ }
164
+
165
+ .hud-subtitle {
166
+ font-size: 11px;
167
+ color: var(--text-muted);
168
+ font-weight: 500;
169
+ }
170
+
171
+ .header-right {
172
+ display: flex;
173
+ gap: 8px;
174
+ }
175
+
176
+ .hud-btn {
177
+ padding: 10px 14px;
178
+ border-radius: 8px;
179
+ border: 1px solid var(--card-border);
180
+ background: rgba(13, 18, 30, 0.6);
181
+ color: var(--text-main);
182
+ font-size: 13px;
183
+ font-weight: 600;
184
+ cursor: pointer;
185
+ display: flex;
186
+ align-items: center;
187
+ gap: 6px;
188
+ transition: all 0.2s;
189
+ }
190
+
191
+ .hud-btn:hover {
192
+ background: rgba(255,255,255,0.08);
193
+ border-color: rgba(255,255,255,0.15);
194
+ }
195
+
196
+ /* ── Middle HUD Info Blocks ───────────────────────────────────────────── */
197
+ .hud-middle {
198
+ display: flex;
199
+ flex: 1;
200
+ justify-content: space-between;
201
+ align-items: flex-start;
202
+ margin: 12px 0;
203
+ gap: 16px;
204
+ width: 100%;
205
+ }
206
+
207
+ /* Left Stats Box */
208
+ .hud-stats-panel {
209
+ padding: 16px;
210
+ min-width: 140px;
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: 12px;
214
+ }
215
+
216
+ .stat-item {
217
+ display: flex;
218
+ flex-direction: column;
219
+ }
220
+
221
+ .stat-label {
222
+ font-size: 10px;
223
+ font-weight: 700;
224
+ text-transform: uppercase;
225
+ letter-spacing: 0.05em;
226
+ color: var(--text-muted);
227
+ }
228
+
229
+ .stat-value {
230
+ font-size: 20px;
231
+ font-weight: 800;
232
+ color: var(--text-main);
233
+ font-family: 'JetBrains Mono', monospace;
234
+ }
235
+
236
+ .stat-unit {
237
+ font-size: 11px;
238
+ font-weight: 500;
239
+ color: var(--text-muted);
240
+ margin-left: 2px;
241
+ }
242
+
243
+ /* Live Target Overlay Box */
244
+ .ai-detection-hud {
245
+ flex: 1;
246
+ max-width: 320px;
247
+ padding: 16px;
248
+ text-align: center;
249
+ align-self: center;
250
+ margin: 0 auto;
251
+ border: 2.5px dashed rgba(255,255,255,0.1);
252
+ transition: all 0.2s;
253
+ }
254
+
255
+ .ai-detection-hud.scanning {
256
+ border-color: rgba(0, 97, 213, 0.4);
257
+ box-shadow: 0 0 16px rgba(0, 97, 213, 0.1);
258
+ }
259
+
260
+ .ai-detection-hud.detected {
261
+ border-style: solid;
262
+ animation: border-glow-pulse 1.5s infinite;
263
+ }
264
+
265
+ @keyframes border-glow-pulse {
266
+ 0% { transform: scale(1); }
267
+ 50% { transform: scale(1.03); }
268
+ 100% { transform: scale(1); }
269
+ }
270
+
271
+ .ai-hud-label {
272
+ font-size: 11px;
273
+ font-weight: 700;
274
+ text-transform: uppercase;
275
+ letter-spacing: 0.08em;
276
+ color: var(--text-muted);
277
+ margin-bottom: 4px;
278
+ }
279
+
280
+ .ai-hud-result {
281
+ font-size: 22px;
282
+ font-weight: 800;
283
+ margin: 6px 0;
284
+ transition: color 0.15s;
285
+ }
286
+
287
+ /* Mini Progress Bar */
288
+ .ai-hud-bar-wrap {
289
+ height: 6px;
290
+ background: rgba(255,255,255,0.1);
291
+ border-radius: 4px;
292
+ overflow: hidden;
293
+ margin: 8px 0;
294
+ }
295
+
296
+ .ai-hud-bar {
297
+ height: 100%;
298
+ width: 0%;
299
+ border-radius: 4px;
300
+ transition: width 0.1s ease-out, background-color 0.15s;
301
+ }
302
+
303
+ .ai-hud-meta {
304
+ font-size: 11px;
305
+ color: var(--text-muted);
306
+ font-family: 'JetBrains Mono', monospace;
307
+ }
308
+
309
+ /* Right Location Box */
310
+ .hud-location-panel {
311
+ padding: 16px;
312
+ max-width: 200px;
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 10px;
316
+ }
317
+
318
+ .loc-row {
319
+ display: flex;
320
+ flex-direction: column;
321
+ }
322
+
323
+ .loc-val {
324
+ font-size: 12px;
325
+ font-weight: 600;
326
+ font-family: 'JetBrains Mono', monospace;
327
+ color: var(--text-main);
328
+ word-break: break-all;
329
+ }
330
+
331
+ .badge-status {
332
+ display: inline-flex;
333
+ align-items: center;
334
+ gap: 6px;
335
+ padding: 4px 8px;
336
+ border-radius: 6px;
337
+ font-size: 11px;
338
+ font-weight: 700;
339
+ margin-top: 4px;
340
+ }
341
+
342
+ .badge-status.active {
343
+ background: rgba(16, 185, 129, 0.15);
344
+ color: var(--color-small);
345
+ border: 1px solid rgba(16, 185, 129, 0.3);
346
+ }
347
+
348
+ .badge-status.inactive {
349
+ background: rgba(239, 68, 68, 0.15);
350
+ color: var(--color-large);
351
+ border: 1px solid rgba(239, 68, 68, 0.3);
352
+ }
353
+
354
+ /* ── Bottom HUD controls ──────────────────────────────────────────────── */
355
+ .hud-footer {
356
+ display: flex;
357
+ align-items: center;
358
+ justify-content: space-between;
359
+ width: 100%;
360
+ gap: 16px;
361
+ z-index: 11;
362
+ }
363
+
364
+ .footer-left {
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 8px;
368
+ }
369
+
370
+ .btn-sound-toggle {
371
+ width: 44px;
372
+ height: 44px;
373
+ border-radius: 10px;
374
+ border: 1px solid var(--card-border);
375
+ background: var(--card-glass);
376
+ color: var(--text-main);
377
+ display: flex;
378
+ align-items: center;
379
+ justify-content: center;
380
+ cursor: pointer;
381
+ font-size: 18px;
382
+ transition: background 0.2s;
383
+ }
384
+
385
+ .btn-sound-toggle.muted {
386
+ color: var(--color-large);
387
+ }
388
+
389
+ .btn-sound-toggle:not(.muted) {
390
+ color: var(--color-small);
391
+ box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
392
+ }
393
+
394
+ /* Drawer Toggle handle */
395
+ .btn-gallery-toggle {
396
+ flex: 1;
397
+ max-width: 220px;
398
+ padding: 12px;
399
+ border-radius: 12px;
400
+ border: 1px solid var(--card-border);
401
+ background: var(--card-glass);
402
+ color: var(--text-main);
403
+ font-size: 13px;
404
+ font-weight: 700;
405
+ cursor: pointer;
406
+ display: flex;
407
+ align-items: center;
408
+ justify-content: center;
409
+ gap: 8px;
410
+ margin: 0 auto;
411
+ box-shadow: 0 4px 15px rgba(0,0,0,0.3);
412
+ }
413
+
414
+ .btn-gallery-toggle svg {
415
+ transition: transform 0.3s;
416
+ }
417
+
418
+ .btn-gallery-toggle.active svg {
419
+ transform: rotate(180deg);
420
+ }
421
+
422
+ /* ── Settings Panel Overlay ───────────────────────────────────────────── */
423
+ .settings-overlay {
424
+ position: absolute;
425
+ inset: 0;
426
+ z-index: 100;
427
+ background: rgba(7, 9, 14, 0.85);
428
+ backdrop-filter: blur(16px);
429
+ -webkit-backdrop-filter: blur(16px);
430
+ display: flex;
431
+ align-items: center;
432
+ justify-content: center;
433
+ padding: 20px;
434
+ opacity: 0;
435
+ pointer-events: none;
436
+ transition: opacity 0.25s ease;
437
+ }
438
+
439
+ .settings-overlay.visible {
440
+ opacity: 1;
441
+ pointer-events: auto;
442
+ }
443
+
444
+ .settings-card {
445
+ width: 100%;
446
+ max-width: 400px;
447
+ border: 1px solid var(--card-border);
448
+ border-radius: 16px;
449
+ background: var(--bg-dark);
450
+ padding: 24px;
451
+ box-shadow: 0 20px 50px rgba(0,0,0,0.6);
452
+ }
453
+
454
+ .settings-header {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: space-between;
458
+ margin-bottom: 20px;
459
+ padding-bottom: 12px;
460
+ border-bottom: 1px solid rgba(255,255,255,0.06);
461
+ }
462
+
463
+ .settings-title {
464
+ font-size: 18px;
465
+ font-weight: 700;
466
+ }
467
+
468
+ .settings-close {
469
+ background: transparent;
470
+ border: none;
471
+ color: var(--text-muted);
472
+ cursor: pointer;
473
+ font-size: 20px;
474
+ }
475
+
476
+ .setting-group {
477
+ margin-bottom: 20px;
478
+ }
479
+
480
+ .setting-label-row {
481
+ display: flex;
482
+ justify-content: space-between;
483
+ align-items: center;
484
+ margin-bottom: 8px;
485
+ }
486
+
487
+ .setting-label {
488
+ font-size: 13px;
489
+ font-weight: 600;
490
+ color: var(--text-main);
491
+ }
492
+
493
+ .setting-value {
494
+ font-size: 12px;
495
+ font-weight: 700;
496
+ color: var(--primary);
497
+ font-family: 'JetBrains Mono', monospace;
498
+ }
499
+
500
+ .slider-input {
501
+ width: 100%;
502
+ height: 6px;
503
+ background: rgba(255,255,255,0.1);
504
+ border-radius: 4px;
505
+ outline: none;
506
+ -webkit-appearance: none;
507
+ }
508
+
509
+ .slider-input::-webkit-slider-thumb {
510
+ -webkit-appearance: none;
511
+ width: 18px;
512
+ height: 18px;
513
+ border-radius: 50%;
514
+ background: var(--primary);
515
+ cursor: pointer;
516
+ box-shadow: 0 0 8px rgba(0,97,213,0.5);
517
+ }
518
+
519
+ .setting-desc {
520
+ font-size: 11px;
521
+ color: var(--text-muted);
522
+ margin-top: 4px;
523
+ line-height: 1.4;
524
+ }
525
+
526
+ .btn-apply-settings {
527
+ width: 100%;
528
+ padding: 12px;
529
+ border-radius: 8px;
530
+ background: var(--primary);
531
+ color: #fff;
532
+ border: none;
533
+ font-size: 14px;
534
+ font-weight: 700;
535
+ cursor: pointer;
536
+ transition: background 0.2s;
537
+ }
538
+
539
+ .btn-apply-settings:hover {
540
+ background: #0052b3;
541
+ }
542
+
543
+ /* ── Sliding Session Log Drawer ───────────────────────────────────────── */
544
+ .gallery-drawer {
545
+ position: absolute;
546
+ left: 0;
547
+ right: 0;
548
+ bottom: 0;
549
+ height: 380px;
550
+ z-index: 50;
551
+ background: #0c121e;
552
+ border-top: 1px solid var(--card-border);
553
+ border-top-left-radius: 20px;
554
+ border-top-right-radius: 20px;
555
+ box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.8);
556
+ transform: translateY(100%);
557
+ transition: transform 0.35s cubic-bezier(0.19, 1, 0.22, 1);
558
+ display: flex;
559
+ flex-direction: column;
560
+ overflow: hidden;
561
+ }
562
+
563
+ .gallery-drawer.open {
564
+ transform: translateY(0);
565
+ }
566
+
567
+ .drawer-header {
568
+ padding: 16px 20px;
569
+ display: flex;
570
+ align-items: center;
571
+ justify-content: space-between;
572
+ border-bottom: 1px solid rgba(255,255,255,0.06);
573
+ }
574
+
575
+ .drawer-title {
576
+ font-size: 15px;
577
+ font-weight: 700;
578
+ display: flex;
579
+ align-items: center;
580
+ gap: 8px;
581
+ }
582
+
583
+ .drawer-count-badge {
584
+ background: rgba(255,255,255,0.1);
585
+ color: var(--text-main);
586
+ padding: 2px 8px;
587
+ border-radius: 12px;
588
+ font-size: 11px;
589
+ font-weight: 700;
590
+ }
591
+
592
+ .btn-clear-session {
593
+ font-size: 12px;
594
+ color: var(--color-large);
595
+ background: transparent;
596
+ border: none;
597
+ cursor: pointer;
598
+ font-weight: 600;
599
+ }
600
+
601
+ .drawer-body {
602
+ flex: 1;
603
+ overflow-y: auto;
604
+ padding: 16px;
605
+ -webkit-overflow-scrolling: touch;
606
+ }
607
+
608
+ /* Empty State */
609
+ .drawer-empty-state {
610
+ height: 100%;
611
+ display: flex;
612
+ flex-direction: column;
613
+ align-items: center;
614
+ justify-content: center;
615
+ color: var(--text-muted);
616
+ font-size: 13px;
617
+ text-align: center;
618
+ padding: 40px;
619
+ }
620
+
621
+ .empty-icon {
622
+ font-size: 32px;
623
+ margin-bottom: 12px;
624
+ opacity: 0.6;
625
+ }
626
+
627
+ /* Gallery List Grid */
628
+ .gallery-grid {
629
+ display: grid;
630
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
631
+ gap: 12px;
632
+ }
633
+
634
+ .gallery-card {
635
+ background: rgba(255,255,255,0.03);
636
+ border: 1px solid rgba(255,255,255,0.06);
637
+ border-radius: 10px;
638
+ overflow: hidden;
639
+ display: flex;
640
+ flex-direction: column;
641
+ }
642
+
643
+ .gallery-thumb-container {
644
+ position: relative;
645
+ height: 90px;
646
+ background: #000;
647
+ }
648
+
649
+ .gallery-thumb {
650
+ width: 100%;
651
+ height: 100%;
652
+ object-fit: cover;
653
+ }
654
+
655
+ .gallery-size-tag {
656
+ position: absolute;
657
+ top: 6px;
658
+ left: 6px;
659
+ padding: 2px 6px;
660
+ border-radius: 4px;
661
+ font-size: 9px;
662
+ font-weight: 700;
663
+ color: #fff;
664
+ }
665
+
666
+ .gallery-info {
667
+ padding: 8px;
668
+ display: flex;
669
+ flex-direction: column;
670
+ gap: 4px;
671
+ }
672
+
673
+ .gallery-time {
674
+ font-size: 10px;
675
+ color: var(--text-muted);
676
+ font-weight: 500;
677
+ }
678
+
679
+ .gallery-coords {
680
+ font-size: 9px;
681
+ font-family: 'JetBrains Mono', monospace;
682
+ color: var(--primary);
683
+ overflow: hidden;
684
+ text-overflow: ellipsis;
685
+ white-space: nowrap;
686
+ }
687
+
688
+ /* ── Hide canvas for frames ───────────────────────────────────────────── */
689
+ #frame-canvas {
690
+ display: none;
691
+ }
692
+
693
+ /* ── Screen layout support for desktop landscape testing ─────────────── */
694
+ @media (min-width: 768px) {
695
+ .hud-layer {
696
+ padding: 24px;
697
+ }
698
+ .hud-stats-panel {
699
+ min-width: 180px;
700
+ }
701
+ .hud-location-panel {
702
+ max-width: 280px;
703
+ }
704
+ }
705
+ </style>
706
+ </head>
707
+ <body>
708
+
709
+ <!-- Hidden canvas for grabbing frames -->
710
+ <canvas id="frame-canvas" width="400" height="300"></canvas>
711
+
712
+ <!-- Fullscreen Camera Preview -->
713
+ <div class="camera-container">
714
+ <video id="webcam-feed" autoplay playsinline muted></video>
715
+ </div>
716
+
717
+ <!-- Live bounding-box overlay (drawn by Roboflow detection engine) -->
718
+ <canvas id="detection-overlay"></canvas>
719
+
720
+ <!-- Screen Red Warning Vignette Flash on Detection -->
721
+ <div class="alert-overlay" id="alert-overlay"></div>
722
+
723
+ <!-- HUD Dashboard UI Overlays -->
724
+ <div class="hud-layer">
725
+
726
+ <!-- HEADER BAR -->
727
+ <header class="hud-header">
728
+ <div class="header-left">
729
+ <button class="btn-back" id="btn-exit" title="Back to submission portal">
730
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
731
+ <line x1="19" y1="12" x2="5" y2="12"></line>
732
+ <polyline points="12 19 5 12 12 5"></polyline>
733
+ </svg>
734
+ </button>
735
+ <div class="hud-title-wrap">
736
+ <h1 class="hud-title" id="hud-title-text">Dashcam Mode</h1>
737
+ <span class="hud-subtitle">Automatic Moving Vehicle Capture</span>
738
+ </div>
739
+ </div>
740
+ <div class="header-right">
741
+ <button class="hud-btn" id="btn-settings-open">
742
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
743
+ <circle cx="12" cy="12" r="3"></circle>
744
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
745
+ </svg>
746
+ Settings
747
+ </button>
748
+ </div>
749
+ </header>
750
+
751
+ <!-- MIDDLE STATS & LIVE AI HUD overlay -->
752
+ <div class="hud-middle">
753
+
754
+ <!-- Left Panel: Session Stats -->
755
+ <div class="glass-panel hud-stats-panel">
756
+ <div class="stat-item">
757
+ <span class="stat-label">Captured</span>
758
+ <span class="stat-value" id="stats-total-potholes">0</span>
759
+ </div>
760
+ <div class="stat-item">
761
+ <span class="stat-label">GPS Speed</span>
762
+ <span class="stat-value" id="stats-speed">0<span class="stat-unit">mph</span></span>
763
+ </div>
764
+ <div class="stat-item">
765
+ <span class="stat-label">Latency</span>
766
+ <span class="stat-value" id="stats-ai-latency">--<span class="stat-unit">ms</span></span>
767
+ </div>
768
+ </div>
769
+
770
+ <!-- Center Overlay: AI Live Bounding Target Box -->
771
+ <div class="glass-panel ai-detection-hud scanning" id="ai-detection-box">
772
+ <div class="ai-hud-label" id="ai-hud-status-text">Continuous Scanning</div>
773
+ <div class="ai-hud-result" id="ai-hud-result-label" style="color:var(--text-muted)">Searching...</div>
774
+ <div class="ai-hud-bar-wrap">
775
+ <div class="ai-hud-bar" id="ai-confidence-bar"></div>
776
+ </div>
777
+ <div class="ai-hud-meta" id="ai-hud-meta-text">Sample Rate: 1.0s</div>
778
+ </div>
779
+
780
+ <!-- Right Panel: GPS Live Location -->
781
+ <div class="glass-panel hud-location-panel">
782
+ <div class="loc-row">
783
+ <span class="stat-label">GPS Lock</span>
784
+ <div class="badge-status inactive" id="gps-lock-badge">
785
+ <span style="width: 6px; height: 6px; background: currentColor; border-radius: 50%;"></span>
786
+ No Signal
787
+ </div>
788
+ </div>
789
+ <div class="loc-row">
790
+ <span class="stat-label">Coordinates</span>
791
+ <span class="loc-val" id="hud-gps-coordinates">Waiting...</span>
792
+ </div>
793
+ <div class="loc-row">
794
+ <span class="stat-label">Precision</span>
795
+ <span class="loc-val" id="hud-gps-precision">--</span>
796
+ </div>
797
+ <div class="loc-row">
798
+ <span class="stat-label">Detected Ward</span>
799
+ <span class="loc-val" id="hud-detected-ward">--</span>
800
+ </div>
801
+ </div>
802
+
803
+ </div>
804
+
805
+ <!-- FOOTER HUD BAR -->
806
+ <footer class="hud-footer">
807
+ <div class="footer-left">
808
+ <!-- Audio trigger -->
809
+ <button class="btn-sound-toggle" id="btn-sound-toggle" title="Toggle audio feedback beeps">
810
+ πŸ”Š
811
+ </button>
812
+ </div>
813
+
814
+ <!-- Drawer Slide Handle -->
815
+ <button class="btn-gallery-toggle" id="btn-gallery-toggle">
816
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
817
+ <polyline points="18 15 12 9 6 15"></polyline>
818
+ </svg>
819
+ Session Log
820
+ <span class="drawer-count-badge" id="stats-drawer-count">0</span>
821
+ </button>
822
+
823
+ <!-- Balanced spacing spacer -->
824
+ <div style="width:44px;"></div>
825
+ </footer>
826
+
827
+ </div>
828
+
829
+ <!-- Sliding Session Log Drawer Gallery -->
830
+ <div class="gallery-drawer" id="gallery-drawer">
831
+ <div class="drawer-header">
832
+ <div class="drawer-title">
833
+ Captured Session Log
834
+ <span class="drawer-count-badge" id="drawer-count-badge-total">0 items</span>
835
+ </div>
836
+ <button class="btn-clear-session" id="btn-clear-session-action">Clear Log</button>
837
+ </div>
838
+
839
+ <div class="drawer-body">
840
+ <!-- Empty state initially -->
841
+ <div class="drawer-empty-state" id="drawer-empty-state">
842
+ <div class="empty-icon">πŸŽ₯</div>
843
+ <p>No potholes auto-captured yet.</p>
844
+ <p style="font-size: 11px; margin-top: 4px; color: var(--text-muted)">Mount your phone on the dash and start driving. Detections will appear here in real time.</p>
845
+ </div>
846
+
847
+ <!-- Gallery Grid -->
848
+ <div class="gallery-grid" id="gallery-grid" style="display:none;"></div>
849
+ </div>
850
+ </div>
851
+
852
+ <!-- Settings Panel Modal -->
853
+ <div class="settings-overlay" id="settings-overlay">
854
+ <div class="settings-card">
855
+ <div class="settings-header">
856
+ <h3 class="settings-title">Dashcam Setup</h3>
857
+ <button class="settings-close" id="btn-settings-close">&times;</button>
858
+ </div>
859
+
860
+ <!-- Sample Interval -->
861
+ <div class="setting-group">
862
+ <div class="setting-label-row">
863
+ <span class="setting-label">AI Scanning Rate</span>
864
+ <span class="setting-value" id="settings-label-rate">1.0s</span>
865
+ </div>
866
+ <input type="range" class="slider-input" id="settings-rate" min="0.5" max="5.0" step="0.5" value="1.0" />
867
+ <p class="setting-desc">How often the camera feeds a frame to the AI. Lower means more frequent checks, consuming more battery.</p>
868
+ </div>
869
+
870
+ <!-- Confidence Threshold -->
871
+ <div class="setting-group">
872
+ <div class="setting-label-row">
873
+ <span class="setting-label">Capture Threshold</span>
874
+ <span class="setting-value" id="settings-label-threshold">72%</span>
875
+ </div>
876
+ <input type="range" class="slider-input" id="settings-threshold" min="30" max="95" step="5" value="72" />
877
+ <p class="setting-desc">Minimum confidence percentage required to auto-record a pothole detection.</p>
878
+ </div>
879
+
880
+ <!-- Detection engine selector -->
881
+ <div class="setting-group">
882
+ <label class="setting-label" style="display:block; margin-bottom: 8px;" for="settings-engine">Detection Engine</label>
883
+ <select id="settings-engine" style="width:100%; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;">
884
+ <option value="roboflow">Roboflow β€” Object Detection (boxes)</option>
885
+ <option value="teachable">Teachable Machine β€” Classification</option>
886
+ </select>
887
+ <p class="setting-desc">Roboflow draws real bounding boxes around each pothole. Teachable Machine classifies the whole frame.</p>
888
+ </div>
889
+
890
+ <!-- Roboflow config (shown when engine = roboflow) -->
891
+ <div class="setting-group" id="settings-roboflow-group">
892
+ <label class="setting-label" style="display:block; margin-bottom: 8px;" for="settings-rf-key">Roboflow Publishable Key</label>
893
+ <input type="text" id="settings-rf-key" placeholder="rf_xxx (publishable key, not the secret API key)" autocomplete="off" style="width:100%; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none; margin-bottom:10px;" />
894
+ <div style="display:flex; gap:8px;">
895
+ <input type="text" id="settings-rf-model" placeholder="model id (e.g. pothole-detection-yolov8-ehkp9)" autocomplete="off" style="flex:1; min-width:0; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;" />
896
+ <input type="number" id="settings-rf-version" min="1" step="1" placeholder="ver" style="width:64px; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;" />
897
+ </div>
898
+ <p class="setting-desc">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.</p>
899
+ </div>
900
+
901
+ <!-- Teachable Machine config (shown when engine = teachable) -->
902
+ <div class="setting-group" id="settings-teachable-group">
903
+ <label class="setting-label" style="display:block; margin-bottom: 8px;" for="settings-model-url">Teachable Machine URL</label>
904
+ <input type="text" id="settings-model-url" style="width:100%; padding:10px; border-radius:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#fff; font-size:12px; outline:none;" />
905
+ <p class="setting-desc">Load custom Google Teachable Machine image classification models.</p>
906
+ </div>
907
+
908
+ <button class="btn-apply-settings" id="btn-settings-apply">Apply Configurations</button>
909
+ </div>
910
+ </div>
911
+
912
+ <!-- ── EXTERNAL LIBRARIES ────────────────────────────────────────────────── -->
913
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
914
+ <!--
915
+ Computer-vision engines are loaded lazily by dashcam-demo.js, on demand,
916
+ so only the selected engine's libraries are downloaded:
917
+ β€’ Roboflow detection β†’ https://cdn.roboflow.com/0.2.26/roboflow.js (bundles its own TFJS)
918
+ β€’ Teachable Machine β†’ @tensorflow/tfjs + @teachablemachine/image
919
+ Loading both statically pulls two TensorFlow.js copies that clash, so we don't.
920
+ -->
921
+
922
+ <!-- ── DOMAIN HELPER SCRIPTS ──────────────────────────────────────────────── -->
923
+ <script src="gis.js"></script>
924
+ <script src="backend-api.js"></script>
925
+ <script src="box-upload.js"></script>
926
+
927
+ <!-- ── CORE APPLICATION CONTROLLER ───────────────────────────────────────── -->
928
+ <script src="dashcam-demo.js"></script>
929
+
930
+ </body>
931
+ </html>
dashcam-demo.js ADDED
@@ -0,0 +1,989 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * dashcam-demo.js β€” Real-Time Mobile Capture Controller
3
+ * Performs edge-inference AI classification on continuous video feed,
4
+ * tracks live coordinates, and handles spatial deduplication.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ (function () {
10
+ // ─── CONFIGURATION & LOCAL STORAGE ───────────────────────────────────────
11
+ const CONFIG = {
12
+ engine: localStorage.getItem('dashcam_engine') || 'roboflow', // 'roboflow' | 'teachable'
13
+ modelUrl: localStorage.getItem('tm_model_url') || 'https://teachablemachine.withgoogle.com/models/3HQTi9DMo/',
14
+ rfKey: localStorage.getItem('roboflow_publishable_key') || '',
15
+ rfModel: localStorage.getItem('roboflow_model_id') || 'pothole-detection-yolov8-ehkp9',
16
+ rfVersion: parseInt(localStorage.getItem('roboflow_model_version') || '1', 10),
17
+ sampleRate: parseFloat(localStorage.getItem('dashcam_sample_rate') || '1.0'),
18
+ threshold: parseFloat(localStorage.getItem('dashcam_threshold') || '72'),
19
+ soundEnabled: localStorage.getItem('dashcam_sound_enabled') !== 'false'
20
+ };
21
+
22
+ // ─── APPLICATION STATE ───────────────────────────────────────────────────
23
+ const state = {
24
+ stream: null,
25
+ tmModel: null,
26
+ rfModel: null, // Loaded Roboflow detection model
27
+ gpsWatcherId: null,
28
+ currentGPS: null,
29
+ lastDetections: [], // Cache for spatial deduplication { lat, lng, timestamp }
30
+ sessionCount: 0,
31
+ aiTimer: null,
32
+ isProcessing: false,
33
+ audioCtx: null
34
+ };
35
+
36
+ // ─── DOM REFERENCES ──────────────────────────────────────────────────────
37
+ const DOM = {
38
+ video: document.getElementById('webcam-feed'),
39
+ canvas: document.getElementById('frame-canvas'),
40
+ overlay: document.getElementById('alert-overlay'),
41
+ boxOverlay: document.getElementById('detection-overlay'),
42
+
43
+ // Stats HUD
44
+ statsTotal: document.getElementById('stats-total-potholes'),
45
+ statsSpeed: document.getElementById('stats-speed'),
46
+ statsLatency: document.getElementById('stats-ai-latency'),
47
+
48
+ // Detection Box HUD
49
+ aiBox: document.getElementById('ai-detection-box'),
50
+ aiStatusText: document.getElementById('ai-hud-status-text'),
51
+ aiResultLabel: document.getElementById('ai-hud-result-label'),
52
+ aiBar: document.getElementById('ai-confidence-bar'),
53
+ aiMetaText: document.getElementById('ai-hud-meta-text'),
54
+
55
+ // Location HUD
56
+ gpsBadge: document.getElementById('gps-lock-badge'),
57
+ gpsCoords: document.getElementById('hud-gps-coordinates'),
58
+ gpsPrecision: document.getElementById('hud-gps-precision'),
59
+ detectedWard: document.getElementById('hud-detected-ward'),
60
+
61
+ // Footer Controls
62
+ btnSound: document.getElementById('btn-sound-toggle'),
63
+ btnGallery: document.getElementById('btn-gallery-toggle'),
64
+ drawerCount: document.getElementById('stats-drawer-count'),
65
+
66
+ // Drawer Gallery
67
+ drawer: document.getElementById('gallery-drawer'),
68
+ drawerCountTotal: document.getElementById('drawer-count-badge-total'),
69
+ drawerEmpty: document.getElementById('drawer-empty-state'),
70
+ galleryGrid: document.getElementById('gallery-grid'),
71
+ btnClearSession: document.getElementById('btn-clear-session-action'),
72
+
73
+ // Settings Panel
74
+ settingsOverlay: document.getElementById('settings-overlay'),
75
+ btnSettingsOpen: document.getElementById('btn-settings-open'),
76
+ btnSettingsClose: document.getElementById('btn-settings-close'),
77
+ btnSettingsApply: document.getElementById('btn-settings-apply'),
78
+
79
+ sliderRate: document.getElementById('settings-rate'),
80
+ sliderThreshold: document.getElementById('settings-threshold'),
81
+ inputModelUrl: document.getElementById('settings-model-url'),
82
+ selectEngine: document.getElementById('settings-engine'),
83
+ inputRfKey: document.getElementById('settings-rf-key'),
84
+ inputRfModel: document.getElementById('settings-rf-model'),
85
+ inputRfVersion: document.getElementById('settings-rf-version'),
86
+ groupRoboflow: document.getElementById('settings-roboflow-group'),
87
+ groupTeachable: document.getElementById('settings-teachable-group'),
88
+
89
+ labelRate: document.getElementById('settings-label-rate'),
90
+ labelThreshold: document.getElementById('settings-label-threshold'),
91
+
92
+ btnExit: document.getElementById('btn-exit')
93
+ };
94
+
95
+ // ─── INIT SOUND (WEB AUDIO API) ──────────────────────────────────────────
96
+ function initAudioContext() {
97
+ if (!state.audioCtx) {
98
+ state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
99
+ }
100
+ if (state.audioCtx.state === 'suspended') {
101
+ state.audioCtx.resume();
102
+ }
103
+ }
104
+
105
+ function playAlertBeep() {
106
+ if (!CONFIG.soundEnabled) return;
107
+ try {
108
+ initAudioContext();
109
+ const ctx = state.audioCtx;
110
+
111
+ // Dual-tone high-frequency notification beep
112
+ const osc1 = ctx.createOscillator();
113
+ const osc2 = ctx.createOscillator();
114
+ const gainNode = ctx.createGain();
115
+
116
+ osc1.type = 'sine';
117
+ osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5 note
118
+ osc1.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.15);
119
+
120
+ osc2.type = 'triangle';
121
+ osc2.frequency.setValueAtTime(440, ctx.currentTime); // A4 note harmony
122
+
123
+ gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
124
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
125
+
126
+ osc1.connect(gainNode);
127
+ osc2.connect(gainNode);
128
+ gainNode.connect(ctx.destination);
129
+
130
+ osc1.start();
131
+ osc2.start();
132
+
133
+ osc1.stop(ctx.currentTime + 0.2);
134
+ osc2.stop(ctx.currentTime + 0.2);
135
+ } catch (e) {
136
+ console.warn('[Sound] Web Audio play failed:', e);
137
+ }
138
+ }
139
+
140
+ // ─── DYNAMIC SETTINGS SYNC ───────────────────────────────────────────────
141
+ function syncSettingsUI() {
142
+ DOM.sliderRate.value = CONFIG.sampleRate;
143
+ DOM.sliderThreshold.value = CONFIG.threshold;
144
+ DOM.inputModelUrl.value = CONFIG.modelUrl;
145
+
146
+ if (DOM.selectEngine) DOM.selectEngine.value = CONFIG.engine;
147
+ if (DOM.inputRfKey) DOM.inputRfKey.value = CONFIG.rfKey;
148
+ if (DOM.inputRfModel) DOM.inputRfModel.value = CONFIG.rfModel;
149
+ if (DOM.inputRfVersion) DOM.inputRfVersion.value = CONFIG.rfVersion;
150
+ toggleEngineSettingGroups();
151
+
152
+ DOM.labelRate.textContent = CONFIG.sampleRate + 's';
153
+ DOM.labelThreshold.textContent = CONFIG.threshold + '%';
154
+
155
+ DOM.aiMetaText.textContent = `Sample Rate: ${CONFIG.sampleRate}s`;
156
+
157
+ if (CONFIG.soundEnabled) {
158
+ DOM.btnSound.classList.remove('muted');
159
+ DOM.btnSound.textContent = 'πŸ”Š';
160
+ } else {
161
+ DOM.btnSound.classList.add('muted');
162
+ DOM.btnSound.textContent = 'πŸ”‡';
163
+ }
164
+ }
165
+
166
+ function toggleEngineSettingGroups() {
167
+ const engine = DOM.selectEngine ? DOM.selectEngine.value : CONFIG.engine;
168
+ if (DOM.groupRoboflow) DOM.groupRoboflow.style.display = engine === 'roboflow' ? 'block' : 'none';
169
+ if (DOM.groupTeachable) DOM.groupTeachable.style.display = engine === 'teachable' ? 'block' : 'none';
170
+ }
171
+
172
+ // ─── CAMERA ACCESS ───────────────────────────────────────────────────────
173
+ async function startCamera() {
174
+ if (state.stream) {
175
+ state.stream.getTracks().forEach(track => track.stop());
176
+ }
177
+
178
+ const constraints = {
179
+ audio: false,
180
+ video: {
181
+ facingMode: { ideal: 'environment' }, // Request back camera primarily
182
+ width: { ideal: 1280 },
183
+ height: { ideal: 720 }
184
+ }
185
+ };
186
+
187
+ try {
188
+ state.stream = await navigator.mediaDevices.getUserMedia(constraints);
189
+ DOM.video.srcObject = state.stream;
190
+ console.info('[Camera] Rear-facing environment camera stream loaded.');
191
+ } catch (err) {
192
+ console.warn('[Camera] Exact environment mode failed, falling back to basic camera constraints.', err);
193
+ try {
194
+ state.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
195
+ DOM.video.srcObject = state.stream;
196
+ } catch (fallbackErr) {
197
+ console.error('[Camera] WebRTC access denied or unavailable.', fallbackErr);
198
+ alert('Could not access device camera. Please verify camera permissions in settings.');
199
+ }
200
+ }
201
+ }
202
+
203
+ // ─── AI INFERENCE LOOP ───────────────────────────────────────────────────
204
+ // Lazily inject a <script> once and resolve when it has loaded. Used to pull
205
+ // each CV engine's libraries on demand so we never load two TFJS copies at once.
206
+ const loadedScripts = {};
207
+ function loadScriptOnce(src) {
208
+ if (loadedScripts[src]) return loadedScripts[src];
209
+ loadedScripts[src] = new Promise((resolve, reject) => {
210
+ const el = document.createElement('script');
211
+ el.src = src;
212
+ el.async = true;
213
+ el.onload = () => resolve();
214
+ el.onerror = () => { delete loadedScripts[src]; reject(new Error(`Failed to load ${src}`)); };
215
+ document.head.appendChild(el);
216
+ });
217
+ return loadedScripts[src];
218
+ }
219
+
220
+ const TFJS_SRC = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest/dist/tf.min.js';
221
+ const TEACHABLE_SRC = 'https://cdn.jsdelivr.net/npm/@teachablemachine/image@latest/dist/teachablemachine-image.min.js';
222
+ const ROBOFLOW_SRC = 'https://cdn.roboflow.com/0.2.26/roboflow.js';
223
+
224
+ async function loadAIModel() {
225
+ // Reset any previously-loaded models so a fresh engine starts clean
226
+ state.tmModel = null;
227
+ state.rfModel = null;
228
+ clearDetectionOverlay();
229
+
230
+ if (CONFIG.engine === 'roboflow') {
231
+ await loadRoboflowModel();
232
+ } else {
233
+ await loadTeachableModel();
234
+ }
235
+ }
236
+
237
+ function setAiStatus(status, result, resultColor) {
238
+ DOM.aiStatusText.textContent = status;
239
+ DOM.aiResultLabel.textContent = result;
240
+ DOM.aiResultLabel.style.color = resultColor || 'var(--text-muted)';
241
+ }
242
+
243
+ async function loadTeachableModel() {
244
+ setAiStatus('Loading AI Model...', 'Initialising...');
245
+
246
+ const url = CONFIG.modelUrl.replace(/\/?$/, '/');
247
+ const modelURL = url + 'model.json';
248
+ const metadataURL = url + 'metadata.json';
249
+
250
+ try {
251
+ // Pull TFJS + Teachable Machine on demand (tfjs must load first)
252
+ await loadScriptOnce(TFJS_SRC);
253
+ await loadScriptOnce(TEACHABLE_SRC);
254
+ if (typeof tmImage === 'undefined') {
255
+ throw new Error('Teachable Machine CDN scripts did not load successfully.');
256
+ }
257
+ state.tmModel = await tmImage.load(modelURL, metadataURL);
258
+ console.info('[AI Model] Teachable Machine model loaded successfully from URL:', url);
259
+
260
+ setAiStatus('Continuous Scanning', 'Searching...');
261
+ DOM.aiBox.classList.remove('scanning');
262
+ DOM.aiBox.classList.add('scanning');
263
+ } catch (err) {
264
+ console.error('[AI Model] Teachable Machine loading failed:', err);
265
+ setAiStatus('AI Unavailable', 'Error Loading Model', 'var(--color-large)');
266
+ }
267
+ }
268
+
269
+ async function loadRoboflowModel() {
270
+ if (!CONFIG.rfKey) {
271
+ setAiStatus('Setup Required', 'Add Roboflow Key', 'var(--color-medium)');
272
+ DOM.aiMetaText.textContent = 'Open Settings β†’ paste publishable key';
273
+ DOM.aiBox.classList.remove('scanning');
274
+ return;
275
+ }
276
+
277
+ setAiStatus('Loading AI Model...', `${CONFIG.rfModel} v${CONFIG.rfVersion}`);
278
+
279
+ try {
280
+ // Pull the Roboflow SDK on demand (bundles its own TFJS)
281
+ await loadScriptOnce(ROBOFLOW_SRC);
282
+ if (typeof roboflow === 'undefined') {
283
+ throw new Error('roboflow.js SDK did not initialise.');
284
+ }
285
+ const publishable = CONFIG.rfKey;
286
+ const model = await roboflow
287
+ .auth({ publishable_key: publishable })
288
+ .load({ model: CONFIG.rfModel, version: CONFIG.rfVersion });
289
+
290
+ // Map the capture threshold (e.g. 72%) into the model's confidence filter
291
+ if (typeof model.configure === 'function') {
292
+ model.configure({ threshold: Math.max(0.05, CONFIG.threshold / 100), overlap: 0.5, max_objects: 20 });
293
+ }
294
+
295
+ state.rfModel = model;
296
+ console.info('[AI Model] Roboflow model loaded:', CONFIG.rfModel, 'v' + CONFIG.rfVersion);
297
+
298
+ setAiStatus('Continuous Scanning', 'Searching...');
299
+ DOM.aiBox.classList.remove('scanning');
300
+ DOM.aiBox.classList.add('scanning');
301
+ } catch (err) {
302
+ console.error('[AI Model] Roboflow loading failed:', err);
303
+ setAiStatus('AI Unavailable', 'Model Load Failed', 'var(--color-large)');
304
+ DOM.aiMetaText.textContent = 'Check key, model id, and version in Settings';
305
+ }
306
+ }
307
+
308
+ function startPredictionTimer() {
309
+ if (state.aiTimer) clearInterval(state.aiTimer);
310
+
311
+ state.aiTimer = setInterval(async () => {
312
+ const ready = CONFIG.engine === 'roboflow' ? state.rfModel : state.tmModel;
313
+ if (!ready || state.isProcessing || DOM.video.paused || DOM.video.ended) return;
314
+
315
+ state.isProcessing = true;
316
+ try {
317
+ if (CONFIG.engine === 'roboflow') {
318
+ await runRoboflowPrediction();
319
+ } else {
320
+ await runTeachablePrediction();
321
+ }
322
+ } catch (err) {
323
+ console.warn('[Inference] Prediction loop encountered an error:', err);
324
+ } finally {
325
+ state.isProcessing = false;
326
+ }
327
+ }, CONFIG.sampleRate * 1000);
328
+ }
329
+
330
+ async function runTeachablePrediction() {
331
+ const t0 = performance.now();
332
+
333
+ // Draw video frame to hidden canvas
334
+ const ctx = DOM.canvas.getContext('2d');
335
+ ctx.drawImage(DOM.video, 0, 0, DOM.canvas.width, DOM.canvas.height);
336
+
337
+ // Run model prediction
338
+ const predictions = await state.tmModel.predict(DOM.canvas);
339
+ DOM.statsLatency.innerHTML = `${Math.round(performance.now() - t0)}<span class="stat-unit">ms</span>`;
340
+
341
+ const sorted = [...predictions].sort((a, b) => b.probability - a.probability);
342
+ const best = sorted[0];
343
+ const confPct = Math.round(best.probability * 100);
344
+
345
+ updateHUDClassification(best.className, confPct);
346
+
347
+ if (confPct >= CONFIG.threshold && ['Small', 'Medium', 'Large'].includes(best.className)) {
348
+ triggerPotholeDetection(best.className, best.probability);
349
+ }
350
+ }
351
+
352
+ async function runRoboflowPrediction() {
353
+ const t0 = performance.now();
354
+
355
+ // Roboflow detects directly on the live <video> element
356
+ const predictions = await state.rfModel.detect(DOM.video);
357
+ DOM.statsLatency.innerHTML = `${Math.round(performance.now() - t0)}<span class="stat-unit">ms</span>`;
358
+
359
+ // Keep only confident pothole boxes
360
+ const boxes = (Array.isArray(predictions) ? predictions : [])
361
+ .filter(p => p && p.bbox && Number.isFinite(p.confidence))
362
+ .sort((a, b) => b.confidence - a.confidence);
363
+
364
+ // Draw every box on the live overlay
365
+ drawDetectionBoxes(boxes);
366
+
367
+ if (!boxes.length) {
368
+ updateHUDClassification('Searching...', 0, true);
369
+ return;
370
+ }
371
+
372
+ const best = boxes[0];
373
+ const confPct = Math.round(best.confidence * 100);
374
+ const size = sizeFromBoundingBox(best.bbox);
375
+
376
+ updateHUDClassification(`${size} pothole`, confPct);
377
+
378
+ // Capture the strongest detection once it clears the threshold
379
+ if (confPct >= CONFIG.threshold) {
380
+ triggerPotholeDetection(size, best.confidence);
381
+ }
382
+ }
383
+
384
+ // Derive a Small/Medium/Large label from how much of the frame the box covers,
385
+ // so Roboflow detections flow through the same pipeline as Teachable Machine.
386
+ function sizeFromBoundingBox(bbox) {
387
+ const frameW = DOM.video.videoWidth || DOM.video.clientWidth || 1;
388
+ const frameH = DOM.video.videoHeight || DOM.video.clientHeight || 1;
389
+ const areaFraction = (bbox.width * bbox.height) / (frameW * frameH);
390
+ if (areaFraction >= 0.06) return 'Large';
391
+ if (areaFraction >= 0.02) return 'Medium';
392
+ return 'Small';
393
+ }
394
+
395
+ // ─── LIVE BOUNDING-BOX OVERLAY ────────────────────────────────────────────
396
+ const SIZE_BOX_COLORS = { Small: '#10b981', Medium: '#f59e0b', Large: '#ef4444' };
397
+
398
+ function clearDetectionOverlay() {
399
+ if (!DOM.boxOverlay) return;
400
+ const ctx = DOM.boxOverlay.getContext('2d');
401
+ ctx.clearRect(0, 0, DOM.boxOverlay.width, DOM.boxOverlay.height);
402
+ }
403
+
404
+ function drawDetectionBoxes(boxes) {
405
+ if (!DOM.boxOverlay) return;
406
+ const canvas = DOM.boxOverlay;
407
+ const ctx = canvas.getContext('2d');
408
+
409
+ // Match the canvas backing store to the displayed video size
410
+ const dispW = DOM.video.clientWidth || canvas.width;
411
+ const dispH = DOM.video.clientHeight || canvas.height;
412
+ if (canvas.width !== dispW || canvas.height !== dispH) {
413
+ canvas.width = dispW;
414
+ canvas.height = dispH;
415
+ }
416
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
417
+ if (!boxes || !boxes.length) return;
418
+
419
+ // The video uses object-fit: cover β€” replicate that mapping so boxes line up
420
+ const vidW = DOM.video.videoWidth || dispW;
421
+ const vidH = DOM.video.videoHeight || dispH;
422
+ const scale = Math.max(dispW / vidW, dispH / vidH);
423
+ const offsetX = (dispW - vidW * scale) / 2;
424
+ const offsetY = (dispH - vidH * scale) / 2;
425
+
426
+ ctx.font = '700 13px Inter, sans-serif';
427
+ ctx.textBaseline = 'bottom';
428
+
429
+ boxes.forEach(p => {
430
+ const size = sizeFromBoundingBox(p.bbox);
431
+ const color = SIZE_BOX_COLORS[size] || '#0061d5';
432
+ // bbox x/y is the box CENTER in source-video pixels
433
+ const w = p.bbox.width * scale;
434
+ const h = p.bbox.height * scale;
435
+ const x = offsetX + (p.bbox.x * scale) - w / 2;
436
+ const y = offsetY + (p.bbox.y * scale) - h / 2;
437
+
438
+ ctx.strokeStyle = color;
439
+ ctx.lineWidth = 3;
440
+ ctx.strokeRect(x, y, w, h);
441
+
442
+ const label = `${size} ${Math.round((p.confidence || 0) * 100)}%`;
443
+ ctx.font = '700 13px Inter, sans-serif';
444
+ const padX = 6;
445
+ const tw = ctx.measureText(label).width + padX * 2;
446
+ const labelY = y > 20 ? y : y + h + 20;
447
+ ctx.fillStyle = color;
448
+ ctx.fillRect(x, labelY - 18, tw, 18);
449
+ ctx.fillStyle = '#fff';
450
+ ctx.fillText(label, x + padX, labelY - 3);
451
+ });
452
+ }
453
+
454
+ function updateHUDClassification(label, confidence, idle = false) {
455
+ if (idle) {
456
+ DOM.aiResultLabel.textContent = label;
457
+ DOM.aiResultLabel.style.color = 'var(--text-muted)';
458
+ DOM.aiBar.style.width = '0%';
459
+ DOM.aiBar.style.backgroundColor = 'var(--color-not-pothole)';
460
+ DOM.aiBox.classList.remove('detected');
461
+ return;
462
+ }
463
+
464
+ DOM.aiResultLabel.textContent = `${label} (${confidence}%)`;
465
+ DOM.aiBar.style.width = `${confidence}%`;
466
+
467
+ // Color by detected size (works for both 'Large' and 'Large pothole' labels)
468
+ let sizeColor = null;
469
+ if (/small/i.test(label)) sizeColor = 'var(--color-small)';
470
+ else if (/medium/i.test(label)) sizeColor = 'var(--color-medium)';
471
+ else if (/large/i.test(label)) sizeColor = 'var(--color-large)';
472
+
473
+ if (confidence < CONFIG.threshold || !sizeColor) {
474
+ DOM.aiResultLabel.style.color = 'var(--text-muted)';
475
+ DOM.aiBar.style.backgroundColor = 'var(--color-not-pothole)';
476
+ DOM.aiBox.classList.remove('detected');
477
+ } else {
478
+ DOM.aiResultLabel.style.color = sizeColor;
479
+ DOM.aiBar.style.backgroundColor = sizeColor;
480
+ DOM.aiBox.classList.add('detected');
481
+ }
482
+ }
483
+
484
+ // ─── GEOLOCATION WATCHER ────────────────────────────────────────────────
485
+ function startGPSWatcher() {
486
+ if (!navigator.geolocation) {
487
+ console.warn('[GPS] Geolocation API not supported by browser.');
488
+ updateGPSHUDStatus(false);
489
+ return;
490
+ }
491
+
492
+ updateGPSHUDStatus(false);
493
+ DOM.gpsCoords.textContent = 'Locking...';
494
+
495
+ state.gpsWatcherId = navigator.geolocation.watchPosition(
496
+ async (position) => {
497
+ const lat = position.coords.latitude;
498
+ const lng = position.coords.longitude;
499
+ const accuracy = position.coords.accuracy || 0;
500
+ const speedMps = position.coords.speed || 0; // Speed in meters per second
501
+
502
+ state.currentGPS = { lat, lng, accuracy, speedMps };
503
+
504
+ updateGPSHUDStatus(true);
505
+ DOM.gpsCoords.textContent = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
506
+ DOM.gpsPrecision.textContent = `Β± ${accuracy.toFixed(1)} m`;
507
+
508
+ // Calculate speed in MPH
509
+ const speedMph = Math.round(speedMps * 2.23694);
510
+ DOM.statsSpeed.innerHTML = `${speedMph}<span class="stat-unit">mph</span>`;
511
+
512
+ // Retrieve regional boundary ward info if GIS config exists
513
+ try {
514
+ if (typeof window.resolveGISMapping === 'function') {
515
+ const gisMapping = await window.resolveGISMapping(lat, lng, { fallbackArea: '' });
516
+ const detectedWard = gisMapping.ward || gisMapping.district || gisMapping.zone || 'Global Domain';
517
+ DOM.detectedWard.textContent = detectedWard;
518
+ } else {
519
+ // Local simple Philly ward bounds detection
520
+ const localWard = detectPhillyWardLocal(lat, lng);
521
+ DOM.detectedWard.textContent = localWard || 'Metro Area';
522
+ }
523
+ } catch (e) {
524
+ DOM.detectedWard.textContent = 'Metro Region';
525
+ }
526
+ },
527
+ (err) => {
528
+ console.warn('[GPS] Error locked or denied:', err);
529
+ updateGPSHUDStatus(false);
530
+ DOM.gpsCoords.textContent = 'No GPS Signal';
531
+ DOM.gpsPrecision.textContent = '--';
532
+ DOM.statsSpeed.innerHTML = `0<span class="stat-unit">mph</span>`;
533
+ },
534
+ { enableHighAccuracy: true, timeout: 8000, maximumAge: 0 }
535
+ );
536
+ }
537
+
538
+ function updateGPSHUDStatus(isLocked) {
539
+ if (isLocked) {
540
+ DOM.gpsBadge.className = 'badge-status active';
541
+ DOM.gpsBadge.innerHTML = `<span style="width:6px;height:6px;background:currentColor;border-radius:50%;"></span>GPS Locked`;
542
+ } else {
543
+ DOM.gpsBadge.className = 'badge-status inactive';
544
+ DOM.gpsBadge.innerHTML = `<span style="width:6px;height:6px;background:currentColor;border-radius:50%;"></span>No Signal`;
545
+ }
546
+ }
547
+
548
+ function detectPhillyWardLocal(lat, lng) {
549
+ if (lat > 40.04 && lng > -75.07) return 'Northeast';
550
+ if (lat > 40.02 && lng < -75.17) return 'Northwest';
551
+ if (lat > 40.02 && lng >= -75.17 && lng <= -75.07) return 'North';
552
+ if (lat >= 39.97 && lat <= 40.02 && lng >= -75.20 && lng <= -75.14) return 'Lower North';
553
+ if (lat >= 39.94 && lat <= 40.04 && lng < -75.18) return 'West';
554
+ if (lat >= 39.95 && lat <= 40.02 && lng > -75.09) return 'River Wards';
555
+ if (lat >= 39.94 && lat <= 40.02 && lng >= -75.18 && lng <= -75.09) return 'Central';
556
+ if (lat >= 39.90 && lat <= 39.95 && lng >= -75.20 && lng <= -75.10) return 'South';
557
+ if (lat < 39.94 && lng < -75.15) return 'Southwest';
558
+ return null;
559
+ }
560
+
561
+ // ─── SPATIAL DEDUPLICATION & DETECTIONS ───────────────────────────────────
562
+ function haversineDistance(lat1, lon1, lat2, lon2) {
563
+ const R = 6371000; // Earth radius in meters
564
+ const phi1 = lat1 * Math.PI / 180;
565
+ const phi2 = lat2 * Math.PI / 180;
566
+ const deltaPhi = (lat2 - lat1) * Math.PI / 180;
567
+ const deltaLambda = (lon2 - lon1) * Math.PI / 180;
568
+
569
+ const a = Math.sin(deltaPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
570
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
571
+ }
572
+
573
+ function checkDeduplication(newLat, newLng) {
574
+ const now = Date.now();
575
+ const COOL_DOWN_RADIUS = 15; // meters
576
+ const COOL_DOWN_TIME = 4000; // milliseconds
577
+
578
+ // Clean expired cooldown entries
579
+ state.lastDetections = state.lastDetections.filter(d => now - d.timestamp < COOL_DOWN_TIME);
580
+
581
+ // Scan for collisions
582
+ for (const d of state.lastDetections) {
583
+ const dist = haversineDistance(d.lat, d.lng, newLat, newLng);
584
+ if (dist <= COOL_DOWN_RADIUS) {
585
+ return false; // Suppressed: duplicate found in close proximity and timeframe
586
+ }
587
+ }
588
+ return true; // Approved: new unique detection
589
+ }
590
+
591
+ // Helper: Convert base64 data to Blob object for box-upload
592
+ function dataURLtoBlob(dataurl) {
593
+ const arr = dataurl.split(',');
594
+ const mime = arr[0].match(/:(.*?);/)[1];
595
+ const bstr = atob(arr[1]);
596
+ let n = bstr.length;
597
+ const u8arr = new Uint8Array(n);
598
+ while (n--) {
599
+ u8arr[n] = bstr.charCodeAt(n);
600
+ }
601
+ return new Blob([u8arr], { type: mime });
602
+ }
603
+
604
+ function generateCaseId() {
605
+ const city = localStorage.getItem('city_name') || '';
606
+ const prefix = (city.replace(/[^A-Za-z]/g, '').slice(0, 3) || 'POT').toUpperCase();
607
+ const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, '');
608
+ const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase();
609
+ return `${prefix}-${datePart}-${randomPart}`;
610
+ }
611
+
612
+ // ─── OFFLINE PERSISTENT UPLOAD QUEUE ─────────────────────────────────────
613
+ let isQueueRunning = false;
614
+
615
+ function getPendingUploads() {
616
+ try {
617
+ return JSON.parse(localStorage.getItem('dashcam_pending_uploads') || '[]');
618
+ } catch (e) {
619
+ return [];
620
+ }
621
+ }
622
+
623
+ function savePendingUploads(queue) {
624
+ localStorage.setItem('dashcam_pending_uploads', JSON.stringify(queue));
625
+ }
626
+
627
+ function saveToHistory(record) {
628
+ try {
629
+ const history = JSON.parse(localStorage.getItem('submitted_cases') || '[]');
630
+ const idx = history.findIndex(x => x.caseId === record.caseId);
631
+ if (idx >= 0) {
632
+ history[idx] = record;
633
+ } else {
634
+ history.push(record);
635
+ }
636
+ localStorage.setItem('submitted_cases', JSON.stringify(history));
637
+ } catch (e) {
638
+ console.warn('[Queue] Failed saving to history:', e);
639
+ }
640
+ }
641
+
642
+ async function processUploadQueue() {
643
+ if (isQueueRunning) return;
644
+ const queue = getPendingUploads();
645
+ if (queue.length === 0) return;
646
+
647
+ isQueueRunning = true;
648
+ console.log(`[Queue] Processing ${queue.length} pending dashcam uploads...`);
649
+
650
+ for (const item of queue) {
651
+ updateGalleryCardStatus(item.caseId, 'syncing');
652
+ try {
653
+ const blob = dataURLtoBlob(item.photoDataUrl);
654
+
655
+ // 1. Submit to Box (uploads photo and metadata sidecar)
656
+ let uploadResult = null;
657
+ if (window.BoxUpload) {
658
+ uploadResult = await window.BoxUpload.submitToBox(blob, item.metadata);
659
+ }
660
+
661
+ const success = uploadResult ? uploadResult.success : false;
662
+
663
+ if (success) {
664
+ console.log(`[Queue] Box upload success for case: ${item.caseId}`);
665
+
666
+ // 2. Build full case record for backend database
667
+ const caseRecord = {
668
+ caseId: item.caseId,
669
+ status: 'Submitted',
670
+ ts: item.timestampMs,
671
+ timestamp: item.timestampIso,
672
+ report: {
673
+ ...item.metadata,
674
+ photoFileId: uploadResult.photoFileId || '',
675
+ sidecarFileId: uploadResult.sidecarFileId || ''
676
+ }
677
+ };
678
+
679
+ // 3. Upsert to backend database so app stays fast
680
+ if (window.PotholeBackend) {
681
+ await window.PotholeBackend.upsertCase(caseRecord);
682
+ console.log(`[Queue] Backend database sync success for case: ${item.caseId}`);
683
+ }
684
+
685
+ // 4. Update local submission history
686
+ saveToHistory(caseRecord);
687
+
688
+ // Remove from pending queue
689
+ const currentQueue = getPendingUploads().filter(x => x.caseId !== item.caseId);
690
+ savePendingUploads(currentQueue);
691
+
692
+ updateGalleryCardStatus(item.caseId, 'uploaded');
693
+ } else {
694
+ // If Box config is disabled/broken, we fallback to uploading directly to Node database!
695
+ const errorMsg = uploadResult?.error || '';
696
+ if (errorMsg.includes('Box is not configured') || errorMsg.includes('Box authentication')) {
697
+ console.log(`[Queue] Box config unavailable. Saving directly to backend database for: ${item.caseId}`);
698
+
699
+ const caseRecord = {
700
+ caseId: item.caseId,
701
+ status: 'Submitted',
702
+ ts: item.timestampMs,
703
+ timestamp: item.timestampIso,
704
+ report: item.metadata
705
+ };
706
+
707
+ if (window.PotholeBackend) {
708
+ await window.PotholeBackend.upsertCase(caseRecord);
709
+ console.log(`[Queue] Saved directly to database successfully: ${item.caseId}`);
710
+ }
711
+
712
+ saveToHistory(caseRecord);
713
+
714
+ const currentQueue = getPendingUploads().filter(x => x.caseId !== item.caseId);
715
+ savePendingUploads(currentQueue);
716
+
717
+ updateGalleryCardStatus(item.caseId, 'uploaded');
718
+ } else {
719
+ throw new Error(errorMsg || 'Box upload failed.');
720
+ }
721
+ }
722
+ } catch (err) {
723
+ console.warn(`[Queue] Upload failed for case ${item.caseId}. Retrying later.`, err);
724
+ updateGalleryCardStatus(item.caseId, 'failed');
725
+ // Stop queue runner immediately on failure (network is likely offline or weak)
726
+ break;
727
+ }
728
+ }
729
+ isQueueRunning = false;
730
+ }
731
+
732
+ function triggerPotholeDetection(size, confidence) {
733
+ const lat = state.currentGPS ? state.currentGPS.lat : 39.9526; // Default to Philly center if offline
734
+ const lng = state.currentGPS ? state.currentGPS.lng : -75.1652;
735
+
736
+ // Evaluate spatial deduplication
737
+ const isNewUnique = checkDeduplication(lat, lng);
738
+ if (!isNewUnique) {
739
+ console.log(`[Deduplication] Pothole capture suppressed inside 15m radius cooldown at (${lat.toFixed(5)}, ${lng.toFixed(5)}).`);
740
+ return;
741
+ }
742
+
743
+ // Capture success β€” register coordinates in spatial index
744
+ state.lastDetections.push({ lat, lng, timestamp: Date.now() });
745
+
746
+ // Snap a fresh frame from the live video into the hidden canvas, then export.
747
+ // (The Roboflow engine detects on the <video> directly and never touches this
748
+ // canvas, so we must draw the current frame here for an accurate snapshot.)
749
+ try {
750
+ const snapCtx = DOM.canvas.getContext('2d');
751
+ snapCtx.drawImage(DOM.video, 0, 0, DOM.canvas.width, DOM.canvas.height);
752
+ } catch (e) {
753
+ console.warn('[Capture] could not draw video frame to snapshot canvas:', e);
754
+ }
755
+ const imageBase64 = DOM.canvas.toDataURL('image/jpeg', 0.82);
756
+
757
+ // Audio & Screen Flash visual alert
758
+ playAlertBeep();
759
+ DOM.overlay.classList.remove('flash');
760
+ void DOM.overlay.offsetWidth; // Force CSS reflow
761
+ DOM.overlay.classList.add('flash');
762
+
763
+ state.sessionCount++;
764
+ DOM.statsTotal.textContent = state.sessionCount;
765
+ DOM.drawerCount.textContent = state.sessionCount;
766
+ DOM.drawerCountTotal.textContent = `${state.sessionCount} items`;
767
+
768
+ const caseId = generateCaseId();
769
+ const timestampIso = new Date().toISOString();
770
+ const timestampMs = Date.now();
771
+
772
+ // Build Case Metadata
773
+ const metadata = {
774
+ caseId,
775
+ timestamp: timestampIso,
776
+ city: localStorage.getItem('city_name') || 'Philadelphia',
777
+ department: localStorage.getItem('dept_name') || 'Department of Streets',
778
+ description: 'Auto-captured from moving vehicle.',
779
+ anonymous: true,
780
+ location: { lat, lng, source: 'device' },
781
+ locationSource: 'device',
782
+ address: DOM.gpsCoords.textContent !== 'Waiting...' ? (state.address || DOM.gpsCoords.textContent) : 'Windshield Capture',
783
+ ward: DOM.detectedWard.textContent !== '--' ? DOM.detectedWard.textContent : 'Metro Area',
784
+ aiResult: size,
785
+ aiConfidence: parseFloat(confidence.toFixed(4))
786
+ };
787
+
788
+ // Add entry to persistent upload queue
789
+ const queue = getPendingUploads();
790
+ queue.push({
791
+ caseId,
792
+ metadata,
793
+ photoDataUrl: imageBase64,
794
+ timestampIso,
795
+ timestampMs
796
+ });
797
+ savePendingUploads(queue);
798
+
799
+ // Add entry to UI gallery (starts as queued)
800
+ addPotholeToGallery(caseId, size, confidence, lat, lng, imageBase64, 'queued');
801
+
802
+ // Trigger queue runner immediately
803
+ processUploadQueue();
804
+ }
805
+
806
+ function addPotholeToGallery(caseId, size, confidence, lat, lng, imageData, status = 'queued') {
807
+ // Hide empty state
808
+ DOM.drawerEmpty.style.display = 'none';
809
+ DOM.galleryGrid.style.display = 'grid';
810
+
811
+ const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
812
+ const sizeColors = { Small: '#10b981', Medium: '#f59e0b', Large: '#ef4444' };
813
+ const color = sizeColors[size] || '#6b7280';
814
+ const confPct = Math.round(confidence * 100);
815
+
816
+ const card = document.createElement('div');
817
+ card.className = 'gallery-card';
818
+ card.id = `card-${caseId}`;
819
+
820
+ const statusText = status === 'uploaded' ? '☁️ Sync Complete' : status === 'syncing' ? 'πŸ”„ Syncing' : status === 'failed' ? '❌ Weak Signal' : '⏳ Queued';
821
+ const statusBg = status === 'uploaded' ? 'rgba(16, 185, 129, 0.15)' : status === 'syncing' ? 'rgba(245, 158, 11, 0.15)' : status === 'failed' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 255, 255, 0.08)';
822
+ const statusColor = status === 'uploaded' ? '#10b981' : status === 'syncing' ? '#f59e0b' : status === 'failed' ? '#ef4444' : '#9ca3af';
823
+
824
+ card.innerHTML = `
825
+ <div class="gallery-thumb-container">
826
+ <img src="${imageData}" class="gallery-thumb" alt="Captured Pothole" />
827
+ <span class="gallery-size-tag" style="background:${color}">${size} (${confPct}%)</span>
828
+ </div>
829
+ <div class="gallery-info">
830
+ <span class="gallery-time">πŸ•’ ${timestamp}</span>
831
+ <span class="gallery-coords">${lat.toFixed(5)}, ${lng.toFixed(5)}</span>
832
+ <span id="sync-tag-${caseId}" style="display:inline-block; font-size:10px; font-weight:700; padding:2px 6px; border-radius:4px; margin-top:6px; background:${statusBg}; color:${statusColor}; text-transform:uppercase; text-align:center;">
833
+ ${statusText}
834
+ </span>
835
+ </div>
836
+ `;
837
+
838
+ // Prepend to display latest first
839
+ DOM.galleryGrid.insertBefore(card, DOM.galleryGrid.firstChild);
840
+ }
841
+
842
+ function updateGalleryCardStatus(caseId, status) {
843
+ const el = document.getElementById(`sync-tag-${caseId}`);
844
+ if (!el) return;
845
+
846
+ const statusText = status === 'uploaded' ? '☁️ Sync Complete' : status === 'syncing' ? 'πŸ”„ Syncing' : status === 'failed' ? '❌ Weak Signal' : '⏳ Queued';
847
+ const statusBg = status === 'uploaded' ? 'rgba(16, 185, 129, 0.15)' : status === 'syncing' ? 'rgba(245, 158, 11, 0.15)' : status === 'failed' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 255, 255, 0.08)';
848
+ const statusColor = status === 'uploaded' ? '#10b981' : status === 'syncing' ? '#f59e0b' : status === 'failed' ? '#ef4444' : '#9ca3af';
849
+
850
+ el.textContent = statusText;
851
+ el.style.background = statusBg;
852
+ el.style.color = statusColor;
853
+ }
854
+
855
+ // ─── USER INTERFACE CONTROLS & EVENTS ───────────────────────────────────
856
+ function wireEvents() {
857
+ // Exit button
858
+ DOM.btnExit.addEventListener('click', () => {
859
+ // Clear interval and GPS watchers
860
+ if (state.aiTimer) clearInterval(state.aiTimer);
861
+ if (state.gpsWatcherId) navigator.geolocation.clearWatch(state.gpsWatcherId);
862
+
863
+ // Stop camera tracks
864
+ if (state.stream) {
865
+ state.stream.getTracks().forEach(track => track.stop());
866
+ }
867
+
868
+ // Navigate back
869
+ window.location.href = 'index.html';
870
+ });
871
+
872
+ // Sound toggle button
873
+ DOM.btnSound.addEventListener('click', () => {
874
+ CONFIG.soundEnabled = !CONFIG.soundEnabled;
875
+ localStorage.setItem('dashcam_sound_enabled', CONFIG.soundEnabled);
876
+ initAudioContext();
877
+ syncSettingsUI();
878
+ });
879
+
880
+ // Drawer gallery slide toggle
881
+ DOM.btnGallery.addEventListener('click', () => {
882
+ DOM.drawer.classList.toggle('open');
883
+ DOM.btnGallery.classList.toggle('active');
884
+ });
885
+
886
+ // Settings overlay toggle
887
+ DOM.btnSettingsOpen.addEventListener('click', () => {
888
+ DOM.settingsOverlay.classList.add('visible');
889
+ });
890
+
891
+ DOM.btnSettingsClose.addEventListener('click', () => {
892
+ DOM.settingsOverlay.classList.remove('visible');
893
+ });
894
+
895
+ // Engine selector toggles which config block is visible
896
+ if (DOM.selectEngine) {
897
+ DOM.selectEngine.addEventListener('change', toggleEngineSettingGroups);
898
+ }
899
+
900
+ // Apply Settings
901
+ DOM.btnSettingsApply.addEventListener('click', () => {
902
+ const newRate = parseFloat(DOM.sliderRate.value);
903
+ const newThreshold = parseFloat(DOM.sliderThreshold.value);
904
+ const newUrl = DOM.inputModelUrl.value.trim();
905
+ const newEngine = DOM.selectEngine ? DOM.selectEngine.value : CONFIG.engine;
906
+ const newRfKey = DOM.inputRfKey ? DOM.inputRfKey.value.trim() : CONFIG.rfKey;
907
+ const newRfModel = DOM.inputRfModel ? DOM.inputRfModel.value.trim() : CONFIG.rfModel;
908
+ const newRfVersion = DOM.inputRfVersion ? (parseInt(DOM.inputRfVersion.value, 10) || 1) : CONFIG.rfVersion;
909
+
910
+ let modelChanged = false;
911
+ if (newEngine !== CONFIG.engine) { CONFIG.engine = newEngine; localStorage.setItem('dashcam_engine', newEngine); modelChanged = true; }
912
+ if (newUrl !== CONFIG.modelUrl) { CONFIG.modelUrl = newUrl; localStorage.setItem('tm_model_url', newUrl); modelChanged = true; }
913
+ if (newRfKey !== CONFIG.rfKey) { CONFIG.rfKey = newRfKey; localStorage.setItem('roboflow_publishable_key', newRfKey); modelChanged = true; }
914
+ if (newRfModel !== CONFIG.rfModel) { CONFIG.rfModel = newRfModel; localStorage.setItem('roboflow_model_id', newRfModel); modelChanged = true; }
915
+ if (newRfVersion !== CONFIG.rfVersion) { CONFIG.rfVersion = newRfVersion; localStorage.setItem('roboflow_model_version', String(newRfVersion)); modelChanged = true; }
916
+
917
+ const thresholdChanged = newThreshold !== CONFIG.threshold;
918
+ CONFIG.sampleRate = newRate;
919
+ CONFIG.threshold = newThreshold;
920
+ localStorage.setItem('dashcam_sample_rate', newRate);
921
+ localStorage.setItem('dashcam_threshold', newThreshold);
922
+
923
+ syncSettingsUI();
924
+ DOM.settingsOverlay.classList.remove('visible');
925
+
926
+ if (modelChanged) {
927
+ loadAIModel();
928
+ } else if (thresholdChanged && CONFIG.engine === 'roboflow' && state.rfModel && typeof state.rfModel.configure === 'function') {
929
+ // Re-apply confidence filter without re-downloading the model
930
+ state.rfModel.configure({ threshold: Math.max(0.05, CONFIG.threshold / 100), overlap: 0.5, max_objects: 20 });
931
+ }
932
+ startPredictionTimer();
933
+ });
934
+
935
+ // Live slider values display
936
+ DOM.sliderRate.addEventListener('input', () => {
937
+ DOM.labelRate.textContent = DOM.sliderRate.value + 's';
938
+ });
939
+
940
+ DOM.sliderThreshold.addEventListener('input', () => {
941
+ DOM.labelThreshold.textContent = DOM.sliderThreshold.value + '%';
942
+ });
943
+
944
+ // Clear session log
945
+ DOM.btnClearSession.addEventListener('click', () => {
946
+ if (confirm('Are you sure you want to clear the captured session log?')) {
947
+ DOM.galleryGrid.innerHTML = '';
948
+ DOM.galleryGrid.style.display = 'none';
949
+ DOM.drawerEmpty.style.display = 'flex';
950
+ state.sessionCount = 0;
951
+
952
+ DOM.statsTotal.textContent = '0';
953
+ DOM.drawerCount.textContent = '0';
954
+ DOM.drawerCountTotal.textContent = '0 items';
955
+
956
+ // Also clear any unsent uploads
957
+ savePendingUploads([]);
958
+ }
959
+ });
960
+
961
+ // Tap target on video to trigger audio permission initialization
962
+ DOM.video.addEventListener('click', () => {
963
+ initAudioContext();
964
+ });
965
+ }
966
+
967
+ // ─── STARTUP ─────────────────────────────────────────────────────────────
968
+ async function init() {
969
+ syncSettingsUI();
970
+ wireEvents();
971
+
972
+ await startCamera();
973
+ await loadAIModel();
974
+
975
+ startPredictionTimer();
976
+ startGPSWatcher();
977
+
978
+ // Start background queue processing runner loop (every 12 seconds)
979
+ setInterval(processUploadQueue, 12000);
980
+ // Process anything remaining from prior session on boot
981
+ processUploadQueue();
982
+ }
983
+
984
+ if (document.readyState === 'loading') {
985
+ document.addEventListener('DOMContentLoaded', init);
986
+ } else {
987
+ init();
988
+ }
989
+ })();