1933 lines
191 KiB
Markdown
1933 lines
191 KiB
Markdown
# Claude.md — JQC Developer Reference
|
||
|
||
> **Audience:** AI assistants and developers working on this codebase.
|
||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact + Phase 51 customer roles: Customer Director / Customer Inspector, both owned by Customer Management, role switching, per-account notification overrides, enrollment form narrowed to the two customer seats)
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Project Overview](#1-project-overview)
|
||
2. [Tech Stack](#2-tech-stack)
|
||
3. [Repository Layout](#3-repository-layout)
|
||
4. [Environment & Configuration](#4-environment--configuration)
|
||
5. [Database Models](#5-database-models)
|
||
6. [Role & Permission Matrix](#6-role--permission-matrix)
|
||
7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory)
|
||
8. [Utility Modules](#8-utility-modules)
|
||
9. [Mobile API (Phase 7 / Phase A–E)](#9-mobile-api-phase-7--phase-ae)
|
||
10. [iPad Native App](#10-ipad-native-app)
|
||
11. [Notification System](#11-notification-system)
|
||
12. [SLA Engine](#12-sla-engine)
|
||
13. [Audit Trail](#13-audit-trail)
|
||
14. [PDF Export](#14-pdf-export)
|
||
15. [Scheduled Reports](#15-scheduled-reports)
|
||
16. [Rate Limiting](#16-rate-limiting)
|
||
17. [Alembic Migration Chain](#17-alembic-migration-chain)
|
||
18. [Frontend Conventions](#18-frontend-conventions)
|
||
19. [Infrastructure](#19-infrastructure)
|
||
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
||
21. [Change Philosophy](#21-change-philosophy)
|
||
22. [Object Storage Migration (R2)](#22-object-storage-migration-r2)
|
||
23. [Photo Capture-Time / Geo Overlay](#23-photo-capture-time--geo-overlay)
|
||
|
||
---
|
||
|
||
## 1. Project Overview
|
||
|
||
**JQC (Janitorial Quality Control)** is a production-grade, full-stack web application that manages:
|
||
|
||
- Janitorial service contracts organised as **Contracts (Projects) → Facilities → Areas**
|
||
- **Inspection** execution against configurable templates with dynamic form builder
|
||
- **Issue** tracking with SLA enforcement, follower subscriptions, and verification workflow
|
||
- **Customer portal** with scoped facility visibility and invitation-based onboarding
|
||
- **Notification** system (in-app + email) driven by an admin-controlled matrix
|
||
- **Reports** — on-demand PDF/CSV/Excel scorecards, scheduled email digests, Issues Aging, SLA Compliance, Follow-up Closure Rate, and per-facility Customer PDF Summary
|
||
- **Audit trail** — immutable log of every create/update/delete action
|
||
- **Support chat** — Groq AI chatbot for customers with preset FAQ chips; escalation to admin via ticketing system; customers can view and reply to their own tickets; admins manage tickets at `/support/admin/tickets`
|
||
- **Mobile API** — JWT-authenticated REST layer for the iPad native app
|
||
- **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B + C complete)
|
||
|
||
The application is actively deployed in production and maintained by a single developer/administrator.
|
||
|
||
---
|
||
|
||
## 2. Tech Stack
|
||
|
||
| Layer | Technology |
|
||
|---|---|
|
||
| Language | Python 3.11+ |
|
||
| Web framework | Flask (application factory pattern) |
|
||
| ORM | Flask-SQLAlchemy (SQLAlchemy 2.x) |
|
||
| Database | MySQL (via PyMySQL driver) |
|
||
| Auth (web) | Flask-Login + Flask-WTF CSRF |
|
||
| Auth (API) | JWT access tokens + opaque refresh tokens (PyJWT) |
|
||
| Rate limiting | Flask-Limiter (Redis-backed in production via `REDIS_URL`; falls back to in-process memory for dev) |
|
||
| Migrations | Flask-Migrate / Alembic |
|
||
| Email | Flask-Mail (SMTP, background threading) |
|
||
| PDF generation | ReportLab |
|
||
| Forms | WTForms + Flask-WTF |
|
||
| Templating | Jinja2 |
|
||
| Frontend | Bootstrap 5, Chart.js, vanilla JS |
|
||
| Server | Gunicorn (sync workers) behind Nginx |
|
||
| OS | Ubuntu Linux |
|
||
| **iPad app** | **SwiftUI + SwiftData, iOS 17+, Xcode 26** |
|
||
| **iPad networking** | **URLSession async/await + NWPathMonitor** |
|
||
| **iPad auth storage** | **iOS Keychain (Security.framework)** |
|
||
| Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) |
|
||
|
||
---
|
||
|
||
## 3. Repository Layout
|
||
|
||
```
|
||
lt_janitorial_quality_control/
|
||
├── app/
|
||
│ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager
|
||
│ ├── api/ # Mobile REST API
|
||
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
|
||
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*
|
||
│ │ ├── facilities.py # /api/v1/facilities/* (Phase A)
|
||
│ │ ├── templates.py # /api/v1/templates/* (Phase A)
|
||
│ │ ├── inspections.py # /api/v1/inspections/* (Phase B)
|
||
│ │ ├── issues.py # /api/v1/issues/* (Phase B + Phase 19 + Phase E)
|
||
│ │ ├── photos.py # /api/v1/photos/upload (Phase B)
|
||
│ │ ├── stats.py # /api/v1/stats/dashboard (Phase B stats)
|
||
│ │ ├── comments.py # /api/v1/issues/<id>/comments (Phase D)
|
||
│ │ ├── decorators.py # @jwt_required
|
||
│ │ ├── errors.py # JSON error helpers + error handler registration
|
||
│ │ └── jwt_utils.py # generate_access_token()
|
||
│ ├── models/
|
||
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
||
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
||
│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23)
|
||
│ │ └── ...
|
||
│ ├── routes/
|
||
│ │ ├── support.py # /support/* — AI chat, ticket submit/list/detail (Phase 23)
|
||
│ │ └── ...
|
||
│ ├── static/
|
||
│ │ └── uploads/ # UPLOAD_FOLDER root
|
||
│ │ ├── inspection_photos/
|
||
│ │ ├── issue_photos/ # photo_path and mobile_photo_paths files
|
||
│ │ └── issue_result_photos/ # result_photos files (web-added resolution photos)
|
||
│ ├── templates/
|
||
│ │ ├── issues/
|
||
│ │ │ ├── view.html # Shows photo_path + mobile_photo_paths under "Photo Evidence"
|
||
│ │ │ └── issues_view.html # Same photo evidence logic
|
||
│ │ ├── reports/
|
||
│ │ │ ├── _subnav.html # Shared sub-nav include for all report pages
|
||
│ │ │ ├── index.html # Overview & Trends (score trend, facility scores + per-Contract filter, charts)
|
||
│ │ │ ├── facility.html # Per-facility detail report
|
||
│ │ │ ├── scorecard.html # Per-facility scorecard (trend, area scores, SLA, open issues) + PDF Summary button
|
||
│ │ │ ├── inspector_performance.html # Inspector KPI table + drill-down chart
|
||
│ │ │ ├── issues_aging.html # Open issues grouped by age bucket (R1)
|
||
│ │ │ ├── sla_compliance.html # SLA compliance by severity and facility (R2)
|
||
│ │ │ └── followup_closure.html # Follow-up re-inspection closure rate (R3)
|
||
│ │ ├── scheduled_reports/
|
||
│ │ │ └── index.html # Includes _subnav.html for Reports sub-nav
|
||
│ │ └── support/
|
||
│ │ ├── chat.html # Customer AI chatbot + FAQ chips + submit-ticket modal
|
||
│ │ ├── my_tickets.html # Customer: list of own tickets
|
||
│ │ ├── my_ticket_detail.html # Customer: ticket detail + staff replies + follow-up form
|
||
│ │ ├── admin_tickets.html # Admin: paginated ticket list with status filter tabs
|
||
│ │ └── admin_ticket_detail.html # Admin: ticket detail + reply form + status controls
|
||
│ └── utils/
|
||
├── migrations/
|
||
│ └── versions/
|
||
│ └── phase36_scheduled_inspections.py ← HEAD
|
||
└── ...
|
||
|
||
Note: `app/routes/broadcast.py` + `app/models/broadcast.py` (admin broadcasts) and
|
||
`app/routes/devices.py` (admin device registry, reads `api_device_tokens`) are also
|
||
part of the tree — see §7. Device registration on the API side lives in
|
||
`app/api/auth.py` only (there is no `app/api/devices.py`).
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Environment & Configuration
|
||
|
||
### Required Environment Variables
|
||
|
||
| Variable | Notes |
|
||
|---|---|
|
||
| `SECRET_KEY` | Flask secret — no fallback; startup fails if absent |
|
||
| `DATABASE_URL` | e.g. `mysql+pymysql://user:pass@localhost/jqc` |
|
||
| `MAIL_SERVER` | SMTP hostname |
|
||
| `MAIL_USERNAME` | SMTP login |
|
||
| `MAIL_PASSWORD` | SMTP password |
|
||
| `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects flags |
|
||
| `APP_BASE_URL` | Full URL for email links |
|
||
| `MAIL_DEFAULT_SENDER` | From address |
|
||
| `DIGEST_SECRET` | Authenticates all cron endpoints |
|
||
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
||
| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
|
||
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `_DEFAULT_GROQ_MODEL` in `routes/support.py` (`openai/gpt-oss-120b`, verified Aug 2026). **Groq retires models without notice** — when the configured one disappears the API 404s and EVERY question returns the generic "problem reaching the AI assistant" reply, with nothing else broken, so it stays invisible until a customer complains. That is how `llama-3.3-70b-versatile` took the chat down. The error handler in `chat_message()` logs the model name and an explicit "set GROQ_MODEL" hint for exactly this case; fixing it needs no deploy, just the env var. |
|
||
| `ENROLLMENT_NOTIFY_EMAILS` | Optional. Comma-separated extra addresses alerted on a new enrollment, **in addition to** every active `admin` account. For people who should be told but hold no JQC login. |
|
||
| `ENROLLMENT_DIR` | Optional. Directory for enrollment-form JSON submissions. Defaults to `<instance_path>/enrollments` (git-ignored). Created at boot. |
|
||
| `DEFAULT_UI_THEME` | Optional, default `modern` (phase50). The design shown when a user has no stored preference — i.e. new accounts and unauthenticated pages. A stored `users.ui_theme` always wins. Set `classic` to revert the default **without** touching anyone's saved choice. |
|
||
| `COMMENTS_VISIBLE_TO_ALL` | Optional, default `true`. **TEMPORARY (Aug 2026).** When true, customers see *every* comment on an issue, not only those ticked "Share with customer". Set `false` to restore the phase22 staff-only filtering — `is_customer_visible` is still written on every comment, so the revert needs no data repair. |
|
||
| `PHOTO_STAMP_ENABLED` | Optional, default `true`. Burns a capture-time + geo overlay into photos uploaded via `POST /api/v1/photos/upload`. Set `false` to store raw uploads. |
|
||
|
||
### Email SSL Auto-Detection
|
||
|
||
```python
|
||
MAIL_USE_SSL = _mail_port == 465
|
||
MAIL_USE_TLS = not MAIL_USE_SSL
|
||
```
|
||
|
||
**Critical:** Never set both to `True` — Flask-Mail breaks silently.
|
||
|
||
### File Uploads
|
||
|
||
- `UPLOAD_FOLDER` = `app/static/uploads/`
|
||
- `MAX_CONTENT_LENGTH` = 50 MB
|
||
- Allowed: `png`, `jpg`, `jpeg`, `gif`
|
||
|
||
---
|
||
|
||
## 5. Database Models
|
||
|
||
### User
|
||
|
||
```
|
||
users: id, username (unique, indexed), full_name, email (unique, indexed),
|
||
password_hash, role (ENUM), created_at, active,
|
||
password_set, set_password_token (indexed), set_password_token_expires
|
||
```
|
||
|
||
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor`, `external_inspector`
|
||
|
||
### The two customer-side roles (Phase 51)
|
||
|
||
Both roles below belong to the **customer**, not to us. They are the two seats the enrollment form offers, and both are created, invited, assigned, switched and disabled in **Customer Management** (`/customers`) — User Management excludes them entirely.
|
||
|
||
| Stored ENUM value | Display label | Scoped by | Capabilities |
|
||
|---|---|---|---|
|
||
| `customer` | **Customer Director** | `CustomerAssignment` (contract **or** single facility) | The portal, unchanged — read-mostly, own-facility issues/comments/follow-up requests |
|
||
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts, **plus the customer support surface** (AI chat + tickets) — see §7 `support` |
|
||
|
||
**This is a LABEL-only rename** — the same posture as rule 19 ("Project" → "Contract"). The ENUM values are unchanged, so phase51 needed **no user migration** and moved none of the ~63 `external_inspector` call sites or the many `role == 'customer'` checks. `User.ROLE_LABELS` is the one place the names live.
|
||
|
||
**`CUSTOMER_ROLES` is not interchangeable with `role == 'customer'` — see rule 89.** `User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and `User.is_customer_account` answer an *account-management* question ("is this managed under /customers?"). Every *capability* check — the portal gates, `@customer_required`, `get_customer_scope()`, support chat, the customer branch in each API module, `notify_customers_for_facility()` — must keep testing `role == 'customer'` exactly, because a Customer Inspector is an **inspector** there.
|
||
|
||
**Switching between them** — `POST /customers/<id>/switch-role`, admin-only. The two roles read different scoping tables, so the switch **mirrors the contracts across** (a bare role flip would leave the account correctly labelled and seeing nothing). Rows for the role being left are **kept**, not deleted. Two consequences worth knowing:
|
||
- Director → Inspector **widens** any facility-level narrowing to the whole contract — inspectors have no per-facility row. The confirm dialog and the flash both say so.
|
||
- Inspector → Director is **lossless on a round trip**: the reverse mirror skips contracts the account can already reach *by any* `CustomerAssignment` row, so it cannot stack a contract-wide grant on top of the original facility-level one.
|
||
|
||
API access changes in both directions (`external_inspector` has mobile-API access, `customer` is 403 everywhere), so the switch **revokes all `api_refresh_tokens` and deletes `api_device_tokens`** for the account — otherwise an issued JWT would keep working until expiry and a signed-in iPad would keep syncing.
|
||
|
||
**`external_inspector` (Phase 49) details, still current:** it has **exactly the same capabilities as `inspector`** and is scoped the **same way** — `InspectorAssignment` rows resolved by `get_inspector_scope()`. Strict scoping applies unchanged: no assignments = sees nothing. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` drive the badges: dashboard **Inspector Activity**, **Inspector Performance** report (HTML badge reads "Customer"; the Excel export suffixes the name cell `(Customer)` rather than gaining a column, so the index-based cell styling stays correct), and every assignee dropdown (`(Customer)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue). Phase 51 changed those strings from "External"; the *attribute* names did not move (rule 84).
|
||
|
||
**Invited, never provisioned.** Neither customer role is given a password we chose. `customers.create()` stores the account with `password_set=False` and a random placeholder hash, mints a 72-hour `set_password_token`, and `_send_invite_email()` sends a link to **`/customers/set-password/<token>`** where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /customers/<id>/resend-invite` mints a fresh token and re-sends — without it a bounced or expired invitation leaves the account permanently unusable. Phase 51 moved this branch out of `auth.create_user()`, which now **requires** a password for every role it still offers (all of them ours).
|
||
|
||
Assignable (rule 80 set: `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access** — `external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`, labelled "Customer Inspector"), whose defaults mirror the Inspector column (see §11), and both customer roles additionally support **per-account overrides** (§11).
|
||
|
||
**`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM.
|
||
|
||
**Key property:** `display_name` → `full_name.strip()` or falls back to `username`.
|
||
|
||
### Facility / Area
|
||
|
||
```
|
||
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK),
|
||
public_token VARCHAR(48) unique ← Phase 34 (QR landing page)
|
||
areas: id, facility_id (FK), name, area_type,
|
||
public_token VARCHAR(48) unique ← Phase 39 (per-area QR landing page)
|
||
```
|
||
|
||
**`public_token`** (Phase 34): unguessable per-facility token encoded in the facility's QR code. The QR points at `/f/<public_token>` — a **login-free** occupant summary page. `Facility.generate_public_token()` / `ensure_public_token()` mint one on demand; new facilities get one at creation, existing rows were backfilled by phase34. Rotating the token (regenerating it) invalidates any printed QR — intentional, for when a code is compromised.
|
||
|
||
**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/<public_token>` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate quality only: rating, counts, trend, and recent inspections with date + quality label — never raw score percentages, checklist/template names, inspector names, per-item scores, or severity/SLA). Routing: `/f/area/<token>` and `/f/<token>` do not collide (tokens are single-segment; `area` is a literal first segment).
|
||
|
||
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
|
||
|
||
### Project / CustomerAssignment
|
||
|
||
```
|
||
projects: id, name, description, project_manager_id, active, created_at
|
||
customer_assignments: id, user_id, project_id, facility_id (nullable)
|
||
UniqueConstraint(user_id, project_id, facility_id)
|
||
inspector_assignments: id, user_id, project_id, created_at
|
||
UniqueConstraint(user_id, project_id, name='uq_inspector_project')
|
||
ForeignKey user_id → users(id) ON DELETE CASCADE
|
||
ForeignKey project_id → projects(id) ON DELETE CASCADE
|
||
```
|
||
|
||
### Inspection
|
||
|
||
```
|
||
inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date,
|
||
overall_score, status (in_progress/completed/flagged), notes, form_data (JSON),
|
||
completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note,
|
||
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
|
||
submit_latitude DECIMAL(10,7) nullable ← Phase 25
|
||
submit_longitude DECIMAL(10,7) nullable ← Phase 25
|
||
```
|
||
|
||
**`mobile_local_id`:** UUID string generated on the iPad. Used for idempotency — if a submission arrives twice (network retry), the server returns the existing record without creating a duplicate. Set `NULL` for all web-created inspections.
|
||
|
||
**Score rule:** Items with `score = 0` mean "unanswered" — excluded from calculation entirely.
|
||
|
||
### Issue
|
||
|
||
```
|
||
issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity (low/medium/high/critical),
|
||
description, photo_path VARCHAR(255), status (open/in_progress/resolved/pending_verification),
|
||
assigned_to, reported_by (nullable FK → users, SET NULL on delete),
|
||
reported_at, resolved_at, result_notes, result_photos (JSON),
|
||
mobile_photo_paths (JSON), ← Phase 19
|
||
verified_by, verified_at, verification_note, sla_notified,
|
||
mobile_local_id VARCHAR(64) nullable indexed, ← Phase B
|
||
vendor_name VARCHAR(100) nullable, ← Phase 26
|
||
vendor_contact VARCHAR(200) nullable, ← Phase 26
|
||
vendor_notes TEXT nullable, ← Phase 26
|
||
handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
|
||
facility_handler_name VARCHAR(100) nullable, ← Phase 35
|
||
facility_handler_contact VARCHAR(200) nullable, ← Phase 35
|
||
facility_handler_notes TEXT nullable, ← Phase 35
|
||
internal_handler_name VARCHAR(100) nullable, ← Phase 41
|
||
internal_handler_contact VARCHAR(200) nullable ← Phase 42
|
||
```
|
||
|
||
**Handler (`handler_type`, Phase 35) — who is doing the work:**
|
||
|
||
| Value | Meaning | Detail fields | `assigned_to` role |
|
||
|---|---|---|---|
|
||
| `internal` (default) | Janitorial Staff (our crew) | `internal_handler_name`/`internal_handler_contact` (Phase 41/42, free text — the crew member's name + phone/email, optional) | the handler |
|
||
| `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** |
|
||
| `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** |
|
||
|
||
**Display labels are perspective-neutral** (they read the same for staff and customers) with a descriptor line under the selector and a tooltip on badges: `internal` → **"Janitorial Staff"** ("Our janitorial crew handles it."), `facility` → **"Facility Staff"** ("The facility's own on-site staff handle it."), `vendor` → **"External Vendor"** ("An outside contractor handles it."). Labels/descriptions live in `Issue.HANDLER_LABELS` / `HANDLER_DESCRIPTIONS`, the WTForms `handler_type` choices, and the `HANDLER_DESC` JS map in both issue templates — keep these in sync. Do **not** use viewer-relative words like "Our"/"Your" for the stored categories.
|
||
|
||
Free-text **`internal_handler_name`** (Phase 41) + **`internal_handler_contact`** (Phase 42, phone/email) capture the janitorial crew member's name and contact when `handler_type == 'internal'` — the actual person doing the work, who may not be a system User. They are distinct from `assigned_to` (the follow-up owner) and are revealed by the same "Handled By" selector JS as the facility/vendor blocks (`#internal_handler_block`). Displayed under a **"Staff"** row (name + contact) on the issue detail when set.
|
||
|
||
`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Settable in **two places**, both with a "Handled By" selector that reveals the janitorial/facility/vendor sub-fields via JS:
|
||
- **Log New Issue** form (`issues/form.html`) — at creation, for non-customer staff. Customer-created issues stay `internal` (the handler UI is hidden for them, same as `assigned_to`).
|
||
- **Update Issue** panel on the issue detail page (`issues/view.html`) — triage after creation.
|
||
|
||
Triage of `handler_type` + facility/vendor detail fields on the **update** panel is **admin/director/project_manager only** (same gate as vendor fields); on the **create** form it follows the form's own access (admin/director create for staff). `assigned_to` editing on update remains admin/director. Issue list is filterable by `?handler_type=` and shows a Facility/Vendor badge. `Issue.handler_label` gives the display string. Not yet exposed in the mobile API.
|
||
|
||
**Photo columns — three distinct fields with different semantics:**
|
||
|
||
| Column | Type | Populated by | Displayed as |
|
||
|---|---|---|---|
|
||
| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) |
|
||
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos; **also public QR "report a problem" (photos 2–5)** | "Photo Evidence" (additional) |
|
||
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
|
||
|
||
**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details".
|
||
|
||
**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue. Nullable for backward compatibility. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet.
|
||
|
||
### TemplateContract (Phase 52)
|
||
|
||
```
|
||
template_contracts: id, template_id (FK→inspection_templates CASCADE, indexed),
|
||
project_id (FK→projects CASCADE, indexed), created_at
|
||
UniqueConstraint(template_id, project_id)
|
||
```
|
||
|
||
**Restricts a form to specific contracts** — a customer's bespoke form must not be visible to, or startable against, another customer's facilities.
|
||
|
||
**No rows means the form is SHARED** (available on every contract), not "available nowhere". That convention is the whole migration story: every template that existed before phase52 has no rows, so nothing changed on deploy, and a form becomes customer-specific only when an admin attaches it to at least one contract. Inverting the default would silently hide every shared form from every contract.
|
||
|
||
`InspectionTemplate` helpers: `contract_ids`, `is_shared`, `available_for_project(project_id)`, `set_contracts([ids])` (does **not** commit), and the static **`available_query(project_id)`** — the single definition of "which forms may this contract use", used by every picker, by the POST validation behind it, and by the mobile API, so they cannot disagree. A facility with **no** contract can only use shared forms (fail-closed).
|
||
|
||
Managed by admin/director in **three** places, because the template screens have three separate edit paths — all must keep the picker or a form silently stays shared:
|
||
|
||
| Where | Route | Notes |
|
||
|---|---|---|
|
||
| **Edit Template modal** on the template list | `POST /templates/<id>/rename` | The one most people actually use. Posts a hidden `contracts_present=1` marker so an empty selection means "make it shared"; a POST **without** the marker (an older client, or another caller of this route) leaves the existing restrictions untouched rather than wiping them. Ids are validated against active contracts. |
|
||
| Create Template | `POST /templates/new` | |
|
||
| Full form editor | `POST /templates/<id>/edit` | `obj=` cannot read association rows, so the multi-select is seeded from `contract_ids` on GET. |
|
||
|
||
`duplicate_template()` copies the restrictions across — duplicating a customer's bespoke form must not yield a copy shared with everyone.
|
||
|
||
The template list shows a **Shared** badge or one badge per contract.
|
||
|
||
### Notification / NotificationPreference
|
||
|
||
```
|
||
notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
|
||
inspection_id, event_type VARCHAR(50) NULL, digest_pending
|
||
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
||
```
|
||
|
||
### IssueComment
|
||
|
||
```
|
||
issue_comments: id, issue_id (FK), user_id (FK), body, created_at,
|
||
status_at_time, is_customer_visible (BOOLEAN, default False) ← Phase 22
|
||
```
|
||
|
||
**`is_customer_visible`:** Staff comments are hidden from customers by default (`False`). Staff can tick "Share with customer" at post time to set `True`. Customer-authored comments are always stored as `True`. Customers see only `is_customer_visible=True` comments; staff see all.
|
||
|
||
### FacilityScoreAlert
|
||
|
||
```
|
||
facility_score_alerts: id, facility_id (FK→facilities CASCADE), sent_at DATETIME,
|
||
current_avg DECIMAL(5,2), prior_avg DECIMAL(5,2), delta DECIMAL(5,2)
|
||
INDEX ix_fsa_facility_sent (facility_id, sent_at)
|
||
```
|
||
|
||
Records each score-trend alert dispatched for a facility. `send_score_alerts()` queries this table to skip re-alerting a facility within the last 24 hours, preventing notification storms on persistent score drops.
|
||
|
||
### SupportTicket / SupportTicketReply
|
||
|
||
```
|
||
support_tickets: id, customer_id (FK→users SET NULL), facility_id (FK→facilities SET NULL),
|
||
subject VARCHAR(200), body TEXT, status VARCHAR(20) DEFAULT 'open',
|
||
created_at DATETIME
|
||
status values: open / answered / closed
|
||
|
||
support_ticket_replies: id, ticket_id (FK→support_tickets CASCADE), user_id (FK→users SET NULL),
|
||
body TEXT, created_at DATETIME
|
||
```
|
||
|
||
### SupportChatSession / SupportChatMessage (Phase 37)
|
||
|
||
```
|
||
support_chat_sessions: id, customer_id (FK→users CASCADE, indexed),
|
||
created_at, updated_at (indexed)
|
||
support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE, indexed),
|
||
role ('user'|'assistant'), content TEXT, created_at
|
||
```
|
||
|
||
Persists the customer AI support chat. `chat_message()` writes both the user turn and the assistant reply into the session (creating one lazily on the first message; `session.updated_at` bumped each turn). `chat()` reloads the customer's **most recent** session into the chat window for continuity (unless `?new=1`). Read-only history views exist for the customer (`/support/my-conversations`) and staff (`/support/admin/conversations`). `SupportChatSession.preview` = first user message; `.message_count` for list views. See §18 "Support Chat".
|
||
|
||
### SupportKnowledge (Phase 38)
|
||
|
||
```
|
||
support_knowledge: id, title VARCHAR(200), content TEXT, active BOOL,
|
||
sort_order INT, created_by (FK→users SET NULL), created_at, updated_at
|
||
```
|
||
|
||
Admin-curated knowledge entries that "train" the AI chatbot **without code changes**. `_system_prompt_with_kb()` in `routes/support.py` appends every **active** entry (ordered by `sort_order`, id) to the base `_SYSTEM_PROMPT` on each chat request, soft-capped at `_KB_MAX_CHARS` (6000). Managed by admin/director at `/support/admin/knowledge` (list/new/edit/delete). The base `_SYSTEM_PROMPT` is a comprehensive, **customer-scoped** description of the app; the KB is the incremental, non-dev-editable layer on top. The chatbot is Groq/Llama (`GROQ_MODEL`, default `llama-3.3-70b-versatile`) — **not** fine-tuned; all "knowledge" is prompt context.
|
||
|
||
**Flow:**
|
||
- Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email)
|
||
- Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/<id>`)
|
||
- Customer adds follow-up → status reverts to `open` → admins notified again
|
||
- Admin can manually set: `open` / `answered` / `closed`
|
||
- Closed tickets cannot receive new replies from customers
|
||
|
||
### NotificationMatrix
|
||
|
||
```
|
||
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||
UniqueConstraint(event_type, role_key)
|
||
```
|
||
|
||
### UserNotificationMatrix (Phase 51)
|
||
|
||
```
|
||
user_notification_matrix: id, user_id (FK→users CASCADE, indexed),
|
||
event_type VARCHAR(50), enabled BOOL
|
||
UniqueConstraint(user_id, event_type)
|
||
```
|
||
|
||
**Per-account override of the global matrix**, for the two customer-side roles only. Each customer organisation states on its enrollment form which notifications each of its people wants, and the global matrix's grain (whole roles) cannot express that.
|
||
|
||
| Row state | Meaning |
|
||
|---|---|
|
||
| `enabled=True` | send even if the global column for this role is OFF |
|
||
| `enabled=False` | do not send even if the global column is ON |
|
||
| **no row** | **inherit** — follow the global column, including later changes to it |
|
||
|
||
Inherit is the default and the safe state, so the table shipped empty and changed routing for nobody. Setting a row back to inherit **deletes** it rather than snapshotting the current global value — that is what keeps an account that never expressed an opinion tracking the global matrix.
|
||
|
||
Helpers in `app/models/user_notification_matrix.py`: `overrides_for_user(user_id)` → `{event: bool}` (the editor), `overrides_for_event(event_type)` → `{user_id: bool}` (one query per dispatch, fails soft to `{}`), `set_overrides(user_id, {event: True|False|None})` (does **not** commit — caller owns the transaction, same contract as `notify()`).
|
||
|
||
Edited admin-side on the account's Customer Management page as a tri-state (Inherit / On / Off) with the global column's current value shown under "Inherit", plus Set-every-row shortcuts. **Each option is a `<label>` filling its table cell** — a bare centred `<input type=radio>` was effectively unclickable at touch/narrow widths, which is what made the editor look broken (see the `ipad_responsive.css` note below).
|
||
|
||
**Enforced in `notify()`, not only in `notify_by_matrix()` (Aug 2026).** A customer account's matrix governs every path that reaches it: matrix broadcasts, follower fan-out (`_notify_followers`), and direct assignee notifications all end at `notify()`. Gating only the matrix meant the editor offered rows — "Issue follow update", "Issue assigned" — that read as Off while the notifications kept arriving. Only an explicit `False` suppresses; no row means inherit. The recipient test uses `getattr(recipient, 'is_customer_account', False)` so an unavailable attribute **sends** rather than silently dropping. Staff roles are unaffected — they use the global matrix alone; `NotificationPreference` remains a different question (how to deliver, not whether to route).
|
||
|
||
### AuditLog
|
||
|
||
```
|
||
audit_logs: id, user_id (nullable), username (snapshot), user_role (snapshot),
|
||
action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address
|
||
```
|
||
|
||
### RefreshToken / DeviceToken
|
||
|
||
```
|
||
api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name,
|
||
created_at, expires_at, revoked
|
||
api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version,
|
||
ios_version, registered_at, last_seen_at ← ios_version + last_seen_at added phase32
|
||
UniqueConstraint(user_id, device_id)
|
||
```
|
||
|
||
`api_device_tokens` is the **single** source of truth for device tracking. It is upserted by `POST /api/v1/devices/register` (in `app/api/auth.py`) on every app foreground and read by the admin Devices page (`/admin/devices`). The earlier `device_registrations` table / `DeviceRegistration` model was removed — see §17 phase30–32.
|
||
|
||
### Broadcast
|
||
|
||
```
|
||
broadcasts: id, title VARCHAR(255), body TEXT, target_roles (JSON list of role strings),
|
||
sent_by_id (FK→users SET NULL), sent_at DATETIME, recipient_count INT
|
||
```
|
||
|
||
Admin-authored broadcast messages. Sending a broadcast fans out one `Notification` row per targeted user; the iPad picks them up through its existing `GET /api/v1/notifications?since=...` poll — **no dedicated broadcast API endpoint exists**. `recipient_count` snapshots how many notifications were created. Managed at `/admin/broadcast` (see §7 `broadcast` blueprint).
|
||
|
||
### ContractNotificationRecipient
|
||
|
||
```
|
||
contract_notification_recipients:
|
||
id, project_id (FK→projects CASCADE, indexed),
|
||
user_id (FK→users CASCADE, nullable, indexed), -- staff-user recipient
|
||
email VARCHAR(200) nullable, -- external email recipient
|
||
event_types TEXT (JSON list of event_type keys), created_at
|
||
```
|
||
|
||
**Per-contract additional notification recipients.** Each row is ONE extra recipient attached to a Contract who is notified — for the `event_types` they subscribe to — whenever those events fire within that contract's facilities, **in addition to** the global `NotificationMatrix` routing. Exactly one of `user_id` / `email` is set (enforced in the route, not the DB):
|
||
|
||
- `user_id` set → existing staff user → **in-app notification + email**
|
||
- `email` set → free-form external address → **email only**
|
||
|
||
`event_types` is a JSON list of `MATRIX_EVENTS` keys. A recipient fires only when the event is in its list. Managed admin-only on the **Contract detail page** (`/projects/<id>`) via `add_notify_recipient` / `remove_notify_recipient`. Dispatch is resolved centrally in `notify_by_matrix()` — see §11.
|
||
|
||
### ScheduledInspection
|
||
|
||
```
|
||
scheduled_inspections:
|
||
id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE),
|
||
inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'),
|
||
next_due_date DATE, active BOOL, notes TEXT, created_by, created_at,
|
||
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL,
|
||
weekdays VARCHAR(20) nullable, -- Phase 43: CSV weekday ints, Mon=0, e.g. '0,2,4'
|
||
month_mode VARCHAR(20) nullable, -- Phase 43: 'day_of_month' | 'nth_weekday'
|
||
day_of_month SMALLINT nullable, -- Phase 43
|
||
nth_week SMALLINT nullable, -- Phase 43: 1–5, or -1 = last
|
||
nth_weekday SMALLINT nullable, -- Phase 43: 0–6, Mon=0
|
||
acknowledged_at DATETIME nullable -- Phase 47: inspector confirmed receipt
|
||
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36
|
||
```
|
||
|
||
**Recurrence detail (Phase 43).** `frequency` says *how often*; these columns say *which day*:
|
||
|
||
| frequency | columns used | example |
|
||
|---|---|---|
|
||
| `once` / `daily` | none (all NULL) | — |
|
||
| `weekly` | `weekdays` | `'0,2,4'` → Mon/Wed/Fri |
|
||
| `monthly` + `month_mode='day_of_month'` | `day_of_month` | the 15th (clamped to the month's last day) |
|
||
| `monthly` + `month_mode='nth_weekday'` | `nth_week`, `nth_weekday` | 2nd Tuesday (`nth_week=-1` → last) |
|
||
|
||
All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_interval()`'s "+7 days" / "same day next month" — no schedule changes cadence on deploy. `_apply_recurrence()` (in the blueprint) **clears the columns that don't apply** to the chosen frequency, so a weekly→monthly switch can't leave stale weekdays behind.
|
||
|
||
- `ScheduledInspection.next_occurrence_after(d)` — first occurrence strictly after `d`, honouring the rule. Used by `fulfill()`; a Mon/Wed/Fri schedule rolls Mon→Wed→Fri→Mon, so **one row yields three inspections a week**.
|
||
- `align_due_date(d)` — snaps the manager's picked start date forward to the first matching day (pick a Tuesday for Mon/Wed/Fri → get that Wednesday). Applied on both create and edit.
|
||
- `recurrence_label` — display string (`"Weekly · Mon, Wed, Fri"`), shown on the schedule list and the dashboard panel in place of the bare `frequency_label`.
|
||
- `weekday_list` / `set_weekdays()` — parse/format the CSV column. `ScheduledInspectionForm(obj=sched)` copies the raw CSV into the multi-select, so the edit route re-assigns `form.weekdays.data = sched.weekday_list` on GET.
|
||
- A requested 5th weekday that doesn't exist in a month falls back to the 4th; `day_of_month=31` clamps to Feb 28/29. Every month yields a valid date.
|
||
|
||
**Duplicate-Start guard.** `start()` returns the existing `in_progress` inspection for the schedule instead of creating a second one, and both the schedule list and the dashboard panel show **Continue** (via `_open_inspection_ids()`) rather than **Start** while one is underway.
|
||
|
||
**A plan, not an inspection.** Names a facility + template + assigned inspector + `next_due_date`. Lifecycle:
|
||
- The assigned inspector (or a manager) clicks **Start** → `scheduled_inspections.start` creates a normal `in_progress` Inspection with `scheduled_inspection_id` set, then redirects to the execute flow.
|
||
- On **completion** (execute route, status → `completed`), `ScheduledInspection.fulfill()` runs in the same atomic commit: `once` → `active=False`; recurring → `next_due_date` rolls forward past today via `_add_interval()` and the three `*_notified` flags reset.
|
||
- **End date (phase44).** `end_date` is the manager's boundary; NULL = forever, and it is forced NULL for `once`. Inclusive — an occurrence landing exactly on it still runs. Two enforcement points, both needed: `fulfill()` deactivates when the rolled-forward `next_due_date` passes the boundary (the schedule that ends by being *completed*), and `run_reminders()` calls `expire_if_past_end_date()` on every active schedule before doing any reminder work (the schedule that reaches its boundary *without ever being done* — otherwise it re-alerts as overdue forever). `next_due_date` is left unclamped on expiry so the row shows which occurrence it stopped before.
|
||
- **Form validation.** `ScheduledInspectionForm.validate()` rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone: `align_due_date()` can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so `_reject_if_past_end_date()` re-checks after `_apply_recurrence()` in both create and edit. Edit rolls back first — `sched` is persistent and already mutated at that point.
|
||
- **Three status states** in the list: Active, **Ended** (`is_expired` — ran its course), Inactive (a manager switched it off).
|
||
- **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`.
|
||
- **Receipt acknowledgement (phase47).** `acknowledged_at` records when the assigned inspector confirms they received the request — a way for the manager to see the assignment was seen, distinct from starting it. `POST /<id>/acknowledge` is **assignee-only** (like Start; a manager cannot confirm on someone's behalf) and idempotent; on first confirm the schedule's **creator** is notified via `_notify_creator_acknowledged()` (`event_type=EVENT_SCHEDULED_INSPECTION`, skipped when creator is inactive or is the inspector). The stamp-log-notify body is factored into `_do_acknowledge(sched, actor_username)`, shared by the POST route and the email-token route. **Once per assignment, not per occurrence:** it is NOT reset when a recurring schedule rolls forward (`fulfill()` leaves it), but the **edit route resets it to NULL when the inspector changes** so a new assignee must re-confirm. Surfaced with a Confirmed/Awaiting badge + a "Confirm receipt" button (assignee only, active schedules) on both the scheduled-inspections **list** and the **dashboard** panel. `ScheduledInspection.is_acknowledged` is the boolean helper. Exposed **read-only** in the API payload (`acknowledged_at`) — confirming stays on the web per rule 77; an iPad confirm action would be a follow-up.
|
||
- **Confirm from the email (phase47).** The assignment email (and the advance/due reminder emails while still unconfirmed) carry a green **"Confirm receipt"** button beside "View Details". It links to `GET /scheduled-inspections/confirm/<token>` — a **login-free** landing (no `@login_required`, same pattern as the `public` blueprint) authorised by an `itsdangerous.URLSafeTimedSerializer` token (salt `scheduled-inspection-ack`, 30-day max age, signed with `SECRET_KEY` — **no DB column**). The token binds `{sid, iid}` so a schedule **reassigned** to another inspector invalidates the previous assignee's emailed link (the route checks `sched.inspector_id == token iid`). The route renders the standalone `scheduled_inspections/confirm_result.html` with a `status` of confirmed / already / reassigned / inactive / expired / invalid / missing; the acknowledgement is idempotent so a re-click or email-client prefetch is harmless. The email button is built by `_confirm_action(sched)` (returns None once acknowledged), threaded into `notify(..., extra_action={'label','url'})` — an **email-only** second button; `extra_action.url` is absolute and is NOT prefixed with `base_url`. In-app notifications are unchanged.
|
||
- **List tabs (phase47).** The scheduled-inspections list (`index()`) has two tabs via `?tab=pending|completed` (default `pending`): **Pending** = active schedules (`active == True`, ordered by next due date), **Completed** = closed schedules (`active == False`, ordered by `last_completed_at` desc — fulfilled one-times, ended recurring, and manually-deactivated rows). Exhaustive, non-overlapping partition; the in-row Status badge (Active / Ended / Inactive) disambiguates the closed bucket, and a "last completed" date is shown when set. `pending_count` / `completed_count` drive the tab pill badges.
|
||
- **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`.
|
||
- Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own).
|
||
- **Instructions (July 2026).** `ScheduledInspectionForm.notes` is labelled **"Instructions"** and `scheduled_inspections/form.html` explains that the text reaches the inspector. The *field name*, `ScheduledInspection.notes`, the `scheduled_inspections.notes` column and the API key `notes` are all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places: `inspections/execute.html` renders an indigo panel between the header and the form grid, guarded on `inspection.scheduled_inspection and .notes` (NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form.
|
||
- **"Scheduled" badge:** an inspection started from a schedule carries `scheduled_inspection_id`. `Inspection.scheduled_inspection` (relationship, foreign_keys on that column) resolves the source schedule (None if ad-hoc or the schedule was later deleted). The inspection **detail** view header shows a `bi-calendar-check` "Scheduled · <frequency>" badge, and the inspection **list** shows a compact "Scheduled" pill next to the template name — both gated on `scheduled_inspection_id` being set.
|
||
|
||
**Customer Directors can plan their own inspections (Aug 2026).** A client asking for an extra clean no longer has to go through us. `@schedule_manager_required` (in the blueprint) = the old `@project_manager_required` set **plus `role == 'customer'`**; `@customer_required` is not reused because this is a capability grant, not a portal gate.
|
||
|
||
Everything a Customer Director sees is narrowed to the facilities on their `CustomerAssignment` rows, and **the narrowed choice lists ARE the POST validation** — `SelectField` rejects anything not offered, so this is the security boundary rather than a tidier dropdown:
|
||
|
||
| List | Narrowed to |
|
||
|---|---|
|
||
| facility_id | `get_customer_scope()` — deliberately breaking rule 61's "all facilities" convention, which exists so the UI-only contract selector cannot fail validation; for a customer the scope is the point |
|
||
| contract selector (`_active_contracts`) | the contracts behind those facilities |
|
||
| template_id | shared forms + those attached to their contracts (phase52), so another customer's bespoke form NAMES never appear (rule 96) |
|
||
| inspector_id | inspectors holding an `InspectorAssignment` on their contracts — theirs and ours, never another client's Customer Inspector (rule 93) |
|
||
|
||
Plus `_reject_facility_out_of_scope()` re-checks the chosen facility after validation (belt-and-braces: the choice-narrowing is a property of how a list was *built*), `_schedule_in_scope()` 403s edit/delete of a schedule outside their facilities, and `index()` filters the list — with `filter(False)` on an empty scope, never a skipped filter (rule 57's failure mode).
|
||
|
||
**Customer Inspectors are excluded** — `_is_customer_director()` tests `role == 'customer'` by equality (rule 89). They *perform* scheduled inspections and are scoped by `InspectorAssignment`; handing them this screen would scope it by the wrong table and show no facilities at all. **Start** is unchanged and still assignee-only, so a Customer Director can plan work but never execute it.
|
||
|
||
Management of the underlying routes is otherwise unchanged; **Start** is the **assigned inspector ONLY** (`sched.inspector_id == current_user.id`) — managers do NOT get a Start button and `GET /<id>/start` 403s for anyone who isn't the assignee (the inspection is theirs to do; a manager who must run it assigns it to themselves). The Start button is hidden for non-assignees on both the scheduled-inspections list and the dashboard panel. Inspectors' list/dashboard views are scoped to their own `inspector_id`.
|
||
|
||
---
|
||
|
||
## 6. Role & Permission Matrix
|
||
|
||
**`auditor` reads as a `project_manager` column** below, with these overrides: **Issues (quick-assign)** ✅, **Issue verification** ✅, and it appears in the **Issues (create/assign)** and **Issue verification** rows as ✅. It never gains issue *delete* or any admin/director-only row PM lacks. See the `auditor` note in §5.
|
||
|
||
| Area | admin | director | project_manager | inspector | customer |
|
||
|---|---|---|---|---|---|
|
||
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
|
||
| Users | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
||
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Facilities | ✅ | ✅ | ✅ | read | scoped |
|
||
| Facility QR (view/print) | ✅ | ✅ | ✅ | ✅ | scoped |
|
||
| Facility QR (regenerate) | ✅ | ✅ | ❌ | ❌ | scoped |
|
||
| Contracts | ✅ | ✅ | ✅ | read | scoped |
|
||
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
|
||
| Inspection follow-up (request) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities |
|
||
| Scheduled inspections (plan) | ✅ | ✅ | ✅ | ❌ | ✅ own contracts |
|
||
| Inspection follow-up (clear) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own |
|
||
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only |
|
||
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ (both customer roles) |
|
||
| Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only |
|
||
| Reports | ✅ | ✅ | ✅ | ✅ | scoped |
|
||
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||
| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
||
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||
|
||
### Decorator Map
|
||
|
||
```python
|
||
@admin_required # role == 'admin' only
|
||
@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes
|
||
@project_manager_required # role in ('admin', 'director', 'project_manager', 'auditor')
|
||
@issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete)
|
||
@customer_required # role == 'customer' only
|
||
|
||
# Not a decorator, but the same idea for the two inspector roles:
|
||
# user.is_inspector → role in ('inspector', 'external_inspector')
|
||
# Never write `role == 'inspector'` for a capability or scoping check.
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Blueprint Prefixes & Route Inventory
|
||
|
||
| Blueprint | Prefix | Notable routes |
|
||
|---|---|---|
|
||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `<label>` wrapping a checkbox; **Print Selected** (JS toggles `body.print-selected-only` so `@media print` hides unticked cards) and **Export Selected to PDF** (`POST /qr/export-pdf`, repeated `facility_ids`/`area_ids`, scope re-checked per id via the `_*_for_qr_or_403()` helpers, streams `generate_qr_codes_pdf()` output; logs `ACTION_EXPORT`). Inspectors 403. QR PNG bytes for the PDF come from `_qr_png_bytes(url)`. |
|
||
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). Report form accepts **up to 5 photos** (`_save_report_photos()` → `photo_path` + `mobile_photo_paths`). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
|
||
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
||
| `customers` | `/customers` | **Owns BOTH customer roles (Phase 51).** `GET /` list (both roles, role badge + per-role scope column), `GET/POST /new` invite (role select: Customer Director / Customer Inspector — same invitation flow for both), `/set-password/<token>`, `GET /<id>` manage, `/<id>/edit`, `POST /<id>/assignments/add` + `/assignments/<aid>/remove` (**director only** — `CustomerAssignment`), `POST /<id>/contracts` (**inspector only** — replaces the whole `InspectorAssignment` set, rule 59 semantics), `POST /<id>/notifications` (per-account matrix overrides), `POST /<id>/switch-role` (**admin only** — mirrors contracts across, revokes tokens/devices), `POST /<id>/toggle-active`, `POST /<id>/resend-invite`, import CSV |
|
||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX), **`POST /bulk`** (bulk export-PDF / request-follow-up / clear-follow-up / delete from the list) |
|
||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **`POST /bulk`** (bulk assign / status / verify / delete from the list). **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** |
|
||
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
|
||
| `audit` | `/audit` | list (admin only), view, purge |
|
||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
||
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
|
||
| `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (`@schedule_manager_required` — admin/director/PM/auditor **plus Customer Directors, scoped to their own contracts**), `GET /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
|
||
| `support` | `/support` | **Customer-facing routes serve BOTH customer roles** (`_is_customer_side()`, Aug 2026) — a Customer Inspector gets the same chat, conversations and tickets, with facilities resolved through `InspectorAssignment` and a role-specific addendum on the AI system prompt. `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||
| `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. |
|
||
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
|
||
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
|
||
| `api` | `/api/v1` | parent blueprint |
|
||
| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` |
|
||
| `api_facilities` | `/api/v1` | `/facilities`, `/facilities/<id>/areas` |
|
||
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
|
||
| `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/<id>` |
|
||
| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos` ← Phase 19 |
|
||
| `api_photos` | `/api/v1` | `POST /photos/upload` |
|
||
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
|
||
| `api_stats` | `/api/v1` | `GET /stats/dashboard` — inspector-scoped KPIs with severity breakdown (Phase B) |
|
||
| `api_comments` | `/api/v1` | `GET /issues/<id>/comments`, `POST /issues/<id>/comments` (Phase D) |
|
||
|
||
---
|
||
|
||
## 8. Utility Modules
|
||
|
||
### `time_utils.py`
|
||
`now_eastern()` — always use this, never `datetime.utcnow()`.
|
||
|
||
### `audit.py`
|
||
`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session.
|
||
|
||
### `mail_utils.py`
|
||
`branded_sender(base_url=None)` — returns a Flask-Mail `(display_name, address)` sender tuple whose identity tracks the current host, used by the customer invite email. Two things vary independently:
|
||
|
||
- **Display name** (always applied, zero DNS): per-domain brand from `BRAND_NAMES` (fallback `DEFAULT_BRAND_NAME`), e.g. `"Gov Services QC"`.
|
||
- **From address** (gated): the authenticated local part (`jqc.noreply`) with the host's registrable domain — but **only** for the authenticated sender's own domain or a domain listed in `SENDER_AUTHORIZED_DOMAINS`. Every other domain keeps the authenticated `MAIL_DEFAULT_SENDER` address so it still passes SPF/DMARC and delivers.
|
||
|
||
So with no DNS work an invite from `jqc.govservicesinc.com` sends `From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>` (branded name, deliverable address). After that domain's SPF `include:` + DKIM are live, add it to `SENDER_AUTHORIZED_DOMAINS` and it upgrades to `<jqc.noreply@govservicesinc.com>` — no code change. Falls back to the bare authenticated sender string for unparseable hosts (localhost, empty). Edit `BRAND_NAMES` / `SENDER_AUTHORIZED_DOMAINS` as brands and DNS come online. See rule 64.
|
||
|
||
### `decorators.py` — `return_url(fallback)`
|
||
|
||
Reads the `next` value a list-page action carried (POST body first, then query string), validates it with `safe_redirect_url`, and falls back. This is what makes an edit or delete return to the **filtered** list instead of the bare index. `next` is the FULL list URL — never a reconstructed argument set — so adding a filter to either list page needs no change here. See §18 "List filter preservation".
|
||
|
||
### `scope.py`
|
||
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers.
|
||
`get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities.
|
||
|
||
### `forms.py`
|
||
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role.
|
||
|
||
### `notifications.py`
|
||
`notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread. `notify()` stores `event_type` on the `Notification` record (phase17+). `flag_followup` route calls `notify()` for the original inspector.
|
||
|
||
### `sla.py`
|
||
`sla_status(issue)` → `'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
|
||
|
||
### `pdf_export.py`
|
||
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
|
||
|
||
**Public functions:**
|
||
- `generate_inspection_pdf(inspection, form_fields, form_data, issues, static_folder)` — per-inspection PDF
|
||
- `generate_issues_list_pdf(issues, filter_summary)` — landscape issues list PDF (from issues list export)
|
||
- `generate_inspections_list_pdf(inspections, filter_summary)` — landscape inspections list PDF
|
||
- `generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)` — customer-facing one-page facility summary PDF (Phase R4)
|
||
- `generate_qr_codes_pdf(items, filter_summary='')` — grid of selected facility/area QR codes. `items` = list of `{title, subtitle, caption, png(bytes)}`; 3-per-row portrait sheet. Backs `POST /facilities/qr/export-pdf`.
|
||
|
||
**`_build_styles()` registered style names:** `ReportTitle`, `ReportSub`, `SectionHead`, `FieldLabel`, `FieldValue`, `MetaLabel`, `MetaValue`, `IssueDesc`, `FooterStyle`, `SummaryTitle`, `ReportSubtitle`, `Meta`, `ScoreValue`, `ScoreLabel`, `SectionHeader`, `TableHeader`, `TableCell`
|
||
|
||
The last eight styles (`SummaryTitle` through `TableCell`) were added for the facility summary PDF and are available for any future customer-facing PDF functions.
|
||
|
||
---
|
||
|
||
## 9. Mobile API (Phase 7 / Phase A–E)
|
||
|
||
### CSRF Exemption Pattern — Critical
|
||
|
||
**`csrf.exempt(api_bp)` does NOT cascade to sub-blueprints.** Each child blueprint must be exempted individually in `app/__init__.py`. The new `api_issues` blueprint (including its `PATCH /issues/<id>/photos` route) inherits the exemption already applied to `_api_issues_bp`. **Every new blueprint must add its own `csrf.exempt()` line before `register_api(app)`.**
|
||
|
||
### Auth Flow
|
||
1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex)
|
||
2. Bearer token on every request
|
||
3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued)
|
||
4. `POST /api/v1/auth/logout` → revokes refresh token
|
||
|
||
### Phase A Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user |
|
||
| `GET /api/v1/facilities/<id>/areas` | jwt_required | Areas for a facility |
|
||
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema). **Contract-scoped (phase52):** an inspector gets shared forms plus those attached to their assigned contracts. Optional `?project_id=` / `?facility_id=` narrows to one contract — **and is intersected with the caller's own scope**, so passing another customer's facility id returns `[]` rather than listing their form names. |
|
||
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema. **404** (not 403) when the form is restricted to a contract the caller cannot reach — whether another customer's form exists is not their business. |
|
||
|
||
### Phase B Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
|
||
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) |
|
||
| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id`; accepts `result_photos` list stored in `mobile_photo_paths` |
|
||
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path`, `stamped`, `captured_at`, `capture_source`. Optional form fields `captured_at` (ISO-8601), `latitude`, `longitude` drive the burned-in timestamp/geo overlay — see §23. |
|
||
|
||
### Phase C Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/inspections` | jwt_required | Inspector's own inspection history (paginated) |
|
||
| `GET /api/v1/issues` | jwt_required | Issues assigned to OR reported by current user (inspectors); all non-resolved (admin/director/PM) |
|
||
| `GET /api/v1/issues/<id>` | jwt_required | Single issue detail |
|
||
| `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status |
|
||
| `GET /api/v1/notifications` | jwt_required | Unread notifications; accepts `?since=<ISO 8601>` |
|
||
| `PATCH /api/v1/notifications/mark-read` | jwt_required | Mark list of notification IDs as read |
|
||
|
||
### Phase 19 Endpoint
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `PATCH /api/v1/issues/<id>/photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. |
|
||
|
||
### Phase B (Stats) Endpoint
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/stats/dashboard` | jwt_required | Inspector-scoped KPIs: `today_inspections`, `completed_today`, `open_issues`, `avg_score_30d`, `pending_followups`, `sla_breached`, `sla_at_risk`, `severity_breakdown` (dict: critical/high/medium/low). Inspectors scoped to contracted facilities. Admins/directors/PMs get org-wide numbers. Customers get 403. |
|
||
|
||
### Phase D (Comments) Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/issues/<id>/comments` | jwt_required | All comments oldest-first. Returns: `id`, `issue_id`, `author_name`, `author_role`, `status_at_time`, `body`, `created_at`. Inspectors limited to contracted facilities. |
|
||
| `POST /api/v1/issues/<id>/comments` | jwt_required | Add a comment. Body: `{ "body": "..." }`. Fires `notify_by_matrix('issue_comment')`. Calls `log_action()` after commit. |
|
||
|
||
### Phase E Additions to Existing Endpoints
|
||
|
||
`_issue_payload()` in `issues.py` now returns `area_name` and `assigned_to_name` (both nullable). These populate `LocalIssue.areaNameCache` and `LocalIssue.assignedToName` on the iPad after every `pullAssignedIssues()`. `refreshStatusFromServer()` also refreshes them on demand.
|
||
|
||
`stats.py` now returns `severity_breakdown` dict alongside the existing KPIs. Derived from the already-loaded `open_issues_all` list — zero extra DB queries.
|
||
|
||
### Scheduled Inspections + Issue Handler Endpoints (July 2026)
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/scheduled-inspections` | jwt_required | Active scheduled/recurring assignments (`app/api/scheduled.py`, new blueprint). **Inspector:** only rows where `inspector_id == self`. **admin/director/PM:** all active. Sorted by `next_due_date`. Returns per row: `id`, `facility_id`, `facility_name`, `template_id`, `template_name`, `inspector_id`, `frequency`, `frequency_label`, `next_due_date` (ISO date), `is_overdue`, `notes`, plus `total`/`limit`/`offset`. Powers the iPad "Scheduled" section on Dashboard + My Inspections. Read-only *as a collection* — schedules are created/edited on the web only — but the iPad **does** fulfil them by submitting an inspection with `scheduled_inspection_id` (see below). |
|
||
| `PATCH /api/v1/issues/<id>/handler` | jwt_required | Set "Handled By" from the iPad (`update_issue_handler`). Body: `{ "handler_type": "internal"\|"facility"\|"vendor", ...optional detail keys }`. Detail keys (`facility_handler_name/contact/notes`, `vendor_name/contact/notes`) are updated only when present; empty string clears a field. **`log_action()` after commit.** |
|
||
|
||
**Handler permission divergence — deliberate (see rule 78).** The web issue form limits handler edits to admin/director/PM. This API endpoint additionally allows the assigned **inspector**, scoped by `get_inspector_scope()` (403 if the issue's facility isn't in their contracted set). The iPad is a field tool; inspectors set the handler from Issue Detail. Do not "align" the API back to the web restriction without explicit direction.
|
||
|
||
**Schedule fulfilment from the iPad (July 2026 fix).** `POST /api/v1/inspections` and `PATCH /api/v1/inspections/<id>` both accept **`scheduled_inspection_id`**, and both call `_fulfill_schedule()` in the same atomic commit when the inspection reaches `completed` — mirroring the web execute route. Previously the iPad's Start passed only facility + template, so the inspection landed with `scheduled_inspection_id = NULL`: the schedule was never fulfilled (banner stayed on every dashboard, iPad "Scheduled" section never cleared) and the web inspection list showed no "Scheduled" badge. All three symptoms had this single cause.
|
||
|
||
- **PATCH fulfils only on the `draft → completed` transition**, so re-PATCHing a completed inspection can't roll a recurring schedule forward twice. POST is guarded by the existing `mobile_local_id` idempotency check (a duplicate returns early, before fulfilment).
|
||
- **`_resolve_schedule()` is deliberately NON-BLOCKING** — see rule 83.
|
||
- `_inspection_payload()` returns `scheduled_inspection_id`.
|
||
- No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the iPad banner clears on the same sync that submits the inspection.
|
||
|
||
The new `scheduled` blueprint is registered in `app/api/__init__.py` and CSRF-exempted in `app/__init__.py` (`csrf.exempt(_api_scheduled_bp)` — parent-exempt does not cascade to child blueprints, per the CSRF pattern above).
|
||
|
||
No migration was needed for either feature: the `scheduled_inspections` table (phase36) and the issue handler columns (phase35) already existed; both additions are pure serialization + one new route.
|
||
|
||
|
||
|
||
```python
|
||
{
|
||
'id', 'status', 'severity', 'description', 'assigned_to',
|
||
'facility_id', 'facility_name', 'reported_at', 'resolved_at',
|
||
'mobile_local_id',
|
||
'photo_path', # primary evidence photo (first iPad photo or web upload)
|
||
'mobile_photo_paths', # extra evidence photos from iPad (list)
|
||
'result_photos', # resolution photos added via web form (list)
|
||
# Phase A additions:
|
||
'result_notes', # resolution notes entered by web staff
|
||
'verified_at', # ISO 8601 datetime when fix was verified (nullable)
|
||
'verification_note', # note from the verifier (nullable)
|
||
'reported_by_name', # display_name of User who filed the issue (nullable)
|
||
# Phase E additions:
|
||
'area_name', # name of the Area the issue was flagged in (nullable)
|
||
'assigned_to_name', # display_name of currently assigned User (nullable)
|
||
# Handler ("Handled By", July 2026) additions:
|
||
'handler_type', # 'internal' | 'facility' | 'vendor' (defaults 'internal')
|
||
'handler_label', # human-readable label (Issue.handler_label property)
|
||
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable
|
||
'vendor_name', 'vendor_contact', 'vendor_notes', # nullable
|
||
'internal_handler_name', 'internal_handler_contact', # Phase 41/42 — janitorial staff name + contact (nullable)
|
||
}
|
||
```
|
||
|
||
**iOS reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. It does NOT read `result_photos` — those are web-only resolution photos.**
|
||
|
||
### Issue API Scope Rules
|
||
|
||
- **Inspector:** `GET /issues` returns issues where `assigned_to == current_user.id` **OR** `reported_by == current_user.id`.
|
||
- **Admin / Director / Project Manager:** `GET /issues` returns all non-resolved issues (default) or filtered by `?status=`.
|
||
- `GET /issues/<id>` and `PATCH /issues/<id>/status` and `PATCH /issues/<id>/photos` all enforce the same combined inspector check.
|
||
|
||
### Photo Upload Flow (multi-photo issues)
|
||
|
||
```
|
||
1. iPad calls POST /api/v1/photos/upload × N → gets N server_path strings
|
||
2. iPad calls POST /api/v1/issues → sends photo_path = paths[0]
|
||
result_photos = paths[1:] (stored in mobile_photo_paths)
|
||
3. iPad calls PATCH /api/v1/issues/<id>/photos → sends result_photos = paths[1:]
|
||
(PATCH is belt-and-suspenders for race safety)
|
||
```
|
||
|
||
Web template shows `photo_path` + `mobile_photo_paths` together under **"Photo Evidence"**. `result_photos` (resolution photos from web form) appears under **"Resolution Details"**.
|
||
|
||
### Facility deduplication
|
||
|
||
`pullReferenceData()` deduplicates the `/api/v1/facilities` response by `id` before upserting. The server may return the same facility ID more than once (one row per contract assignment). Without deduplication, the same building appears twice in every picker. The dedup uses a `seenFacilityIds = Set<Int>()` filter on the iOS side AND the upsert map (`facilityMap`) on the server side.
|
||
|
||
### Idempotency Pattern
|
||
|
||
All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt, check for existing record and return `{ 'duplicate': True }` without inserting. Web-created records have `mobile_local_id = NULL`.
|
||
|
||
### Score Calculation (Server-Side)
|
||
|
||
`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly. Rating value `0` = unanswered → excluded. Returns `float` 0–100 or `None` if no scoreable fields.
|
||
|
||
---
|
||
|
||
## 10. iPad Native App
|
||
|
||
See the iOS app's own `CLAUDE.md` for full details. Key integration points:
|
||
|
||
- App connects to `jqc.ltservicesinc.com` (primary) or `jqc1.ltservicesinc.com` (secondary) — **server is user-selectable at login and in Settings**.
|
||
- Server selection is persisted to `UserDefaults` via `ServerConfig`. Switching server in Settings triggers a logout confirmation alert and clears all server-pulled SwiftData records (`serverId != nil`) before logout.
|
||
- All photo evidence from the iPad routes through `mobile_photo_paths` on the server — never through `result_photos`.
|
||
|
||
---
|
||
|
||
## 11. Notification System
|
||
|
||
### Event Constants (`app/models/notification.py`)
|
||
```
|
||
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
|
||
EVENT_ISSUE_STATUS = 'issue_status'
|
||
EVENT_ISSUE_COMMENT = 'issue_comment'
|
||
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
|
||
EVENT_INSPECTION_DONE = 'inspection_completed'
|
||
EVENT_SLA_ALERT = 'sla_alert'
|
||
EVENT_ISSUE_FLAGGED = 'issue_flagged'
|
||
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
|
||
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
||
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
|
||
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36
|
||
EVENT_FOLLOWUP_REQUESTED = 'followup_requested' ← Phase 46
|
||
```
|
||
|
||
### External Inspector column (Phase 49)
|
||
|
||
`MATRIX_ROLES` gains `('external_inspector', 'External Inspector')`, and `notify_by_matrix()`'s `role_to_db` map routes it to the `external_inspector` DB role. `MATRIX_DEFAULTS` **mirrors** the Inspector column for every event (a comprehension, not 14 more literals) so a future event added for `inspector` automatically gets a matching external default. The `inspection_completed` scoping below applies to **both** inspector columns — without that, enabling the External column would notify every third-party inspector on every submission.
|
||
|
||
### Inspector role scoping for `inspection_completed`
|
||
|
||
`notify_by_matrix()` special-cases the **inspector** role for the `inspection_completed` event: instead of notifying every active inspector, it notifies **only the inspection's own inspector** (`Inspection.inspector_id`, resolved from the passed `inspection_id`). So enabling the "Inspector" column for "Inspection completed" in the matrix alerts just the inspector who submitted that inspection — not the whole inspector pool. All three dispatch sites (web `routes/inspections.py`, both mobile-API `api/inspections.py`) pass `inspection_id`, so the scoping applies uniformly; if `inspection_id` is ever omitted for this event, the inspector role notifies no one (fail-closed). Other roles/events are unaffected.
|
||
|
||
### Customer-requested follow-up (Phase 46)
|
||
|
||
`inspections.flag_followup` is no longer `@supervisor_required`. It gates in the body instead: **admin/director** as before, **plus customers for their own facilities** — a client unhappy with a result asks for a re-inspection directly instead of going through support. Inspector / PM / auditor stay refused (403).
|
||
|
||
Customers can only *request*. `clear_followup` remains admin/director, `reinspect()` still refuses customers, and the "Start Re-inspection" button inside the follow-up alert is hidden from them (it 403'd on click before). Three extra customer-only guards in the route:
|
||
|
||
- facility must be in `get_customer_scope()` — else 403 (a crafted POST must not reach another client's inspection);
|
||
- inspection must be `completed` — nothing to follow up on otherwise;
|
||
- if `follow_up_required` is already set the request is a **no-op**, so a repeat submission can't overwrite the pending note/attribution.
|
||
|
||
**Attribution** (`follow_up_requested_by` / `follow_up_requested_at`, phase46) records who asked and when; `clear_followup` nulls both. `inspections/view.html` renders a "Requested by customer" / "Requested by staff" badge from `inspection.follow_up_requester.role`, so staff can see at a glance that a client is waiting.
|
||
|
||
**Dispatch** goes through `notify_by_matrix(EVENT_FOLLOWUP_REQUESTED, ...)` — the new `followup_requested` matrix event (admin/director/PM on by default). The inspection's own inspector is notified directly by the route and passed in `exclude_user_ids` so they aren't double-notified; the requester is excluded too. Routing via the matrix (rather than hardcoding managers) is what makes per-contract recipients fire — rule 73. Without it a customer request would reach only the inspector and nobody would own scheduling the re-inspection.
|
||
|
||
### Per-Account Overrides for Customer Roles (Phase 51)
|
||
|
||
`notify_by_matrix()` consults `UserNotificationMatrix` (§5) for the two customer-side role columns. One query per dispatch (`overrides_for_event`), then `users = [u for u in users if overrides.get(u.id, enabled)]` — an account with no row falls back to the global column, which is what makes both directions work.
|
||
|
||
**The skip-early guard had to change, and this is the subtle part.** The role loop used to `continue` on `if not enabled` *before* loading the pool, so a per-account opt-IN against a globally-OFF column would have saved fine, displayed as on, and never sent — a silent failure. A customer column is now skipped only when it is off **and** nobody has opted in:
|
||
|
||
```python
|
||
is_customer_col = role_key in User.CUSTOMER_ROLES
|
||
if not enabled and not (is_customer_col and any(overrides.values())):
|
||
continue
|
||
```
|
||
|
||
**`notify_customers_for_facility()` needs the filter passed in.** It re-derives recipients from `CustomerAssignment` rows itself, so the facility-scoped `customer` branch would bypass every override applied a few lines above. `notify_by_matrix()` therefore passes `allowed_user_ids={u.id for u in users}`; direct callers omit it (`None` = no filtering) and behave as before. Its `user.role != 'customer'` check stays an **equality** test — a Customer Inspector is routed by the inspector column, not this one.
|
||
|
||
### Per-Contract Additional Recipients (Phase 33)
|
||
|
||
`notify_by_matrix()` is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls `_notify_contract_recipients()`, which:
|
||
|
||
1. Resolves the owning contract via `_resolve_project_id(facility_id, issue_id, inspection_id)` — tries `facility_id`, then the issue's facility (or `issue.area.facility_id`), then the inspection's facility.
|
||
2. Loads `ContractNotificationRecipient` rows for that project and notifies each one whose `event_types` contains the firing event.
|
||
3. **Deduplicates** against users already notified this dispatch (shared `notified` set) and emails already sent (shared `sent_emails` set), so a user who is both a matrix role AND a contract recipient gets exactly one notification.
|
||
|
||
Contract recipients fire **regardless of matrix role toggles** — they are additive, not gated by the matrix. Staff-user recipients use `respect_preferences=False` (contract config is authoritative, mirroring matrix broadcasts). Commit is the **caller's** responsibility, same as the rest of `notify_by_matrix()`.
|
||
|
||
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
||
|
||
| Endpoint | Purpose | Schedule |
|
||
|---|---|---|
|
||
| `POST /notifications/send-digest` | Digest email delivery | `0 7 * * *` |
|
||
| `POST /notifications/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` |
|
||
| `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` |
|
||
| `POST /notifications/check-score-trends` | Facility score drop alerts (Phase 27) | `0 8 * * *` |
|
||
| `POST /scheduled-inspections/run` | Scheduled inspection reminders — advance/due to inspector, overdue to admin/director (Phase 36) | `*/30 * * * *` |
|
||
|
||
---
|
||
|
||
## 12. SLA Engine
|
||
|
||
| Severity | Window | At-Risk |
|
||
|---|---|---|
|
||
| critical | 4h | 3h |
|
||
| high | 24h | 18h |
|
||
| medium | 72h | 54h |
|
||
| low | 168h | 126h |
|
||
|
||
`issue.sla_notified` prevents duplicate cron notifications.
|
||
|
||
---
|
||
|
||
## 13. Audit Trail
|
||
|
||
- Admin-only at `/audit/` — director is excluded
|
||
- Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT`
|
||
- Mobile API routes call `log_action()` for all create/update operations
|
||
- Immutable — never updated or deleted through the application
|
||
|
||
---
|
||
|
||
## 14. PDF Export
|
||
|
||
ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF.
|
||
|
||
---
|
||
|
||
## 15. Scheduled Reports
|
||
|
||
Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`.
|
||
Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
|
||
|
||
---
|
||
|
||
## 16. Rate Limiting
|
||
|
||
```python
|
||
limiter = Limiter(
|
||
key_func = get_remote_address,
|
||
default_limits = [],
|
||
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
|
||
)
|
||
```
|
||
|
||
**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`.
|
||
|
||
---
|
||
|
||
## 17. Alembic Migration Chain
|
||
|
||
```
|
||
phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix
|
||
→ phase9_user_full_name → phase10_customer_password_setup → phase11_director_role
|
||
→ phase12_performance_indexes → phase_b_mobile_local_id
|
||
→ phase13_issue_facility → phase14_facility_created_at
|
||
→ phase15_audit_log_indexes → phase16_notifications_columns
|
||
→ phase17_notification_event_type
|
||
→ phase18_issue_reported_by
|
||
→ phase19_issue_mobile_photos
|
||
→ phase20_inspector_assignments
|
||
→ phase21_template_active
|
||
→ phase21_performance_indexes
|
||
→ phase22_comment_visibility
|
||
→ phase23_support_tickets
|
||
→ phase24_notify_defaults
|
||
→ phase25_inspection_gps
|
||
→ phase26_issue_vendor
|
||
→ phase27_score_alerts
|
||
→ phase28_fix_inspection_notify
|
||
→ phase29_broadcasts
|
||
→ phase30_device_registry
|
||
→ phase31_device_registry
|
||
→ phase32_device_token_columns
|
||
→ phase33_contract_recipients
|
||
→ phase34_facility_qr
|
||
→ phase35_issue_handler
|
||
→ phase36_scheduled_insp
|
||
→ phase37_support_chat
|
||
→ phase38_support_knowledge
|
||
→ phase39_area_public_token
|
||
→ phase40_auditor_role
|
||
→ phase41_internal_handler
|
||
→ phase42_internal_contact
|
||
→ phase43_sched_recurrence
|
||
→ phase44_sched_end_date
|
||
→ phase45_sched_parent_insp
|
||
→ phase46_followup_req_by
|
||
→ phase47_sched_acknowledged
|
||
→ phase48_user_ui_theme
|
||
→ phase49_external_inspector
|
||
→ phase50_default_modern
|
||
→ phase51_user_notif_matrix
|
||
→ phase52_template_contracts ← HEAD
|
||
|
||
#### phase52 — restrict forms to specific contracts
|
||
|
||
Revision id `phase52_template_contracts`. Creates `template_contracts` — see §5 `TemplateContract`.
|
||
|
||
**No backfill, and it cannot change behaviour on deploy.** Every existing template has no rows, and no rows means *shared with every contract*, which is exactly what they do today. Table-existence check — safe to re-run. `downgrade()` drops the table, returning every form to shared: no form becomes unusable, they just stop being restricted.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
#### phase51 — per-account notification overrides
|
||
|
||
Revision id `phase51_user_notif_matrix` (file `phase51_user_notification_matrix.py`, down_revision `phase50_default_modern`). Creates `user_notification_matrix` — see §5 `UserNotificationMatrix` and §11.
|
||
|
||
**No backfill, deliberately.** An empty table means every account inherits the global matrix, which is exactly today's behaviour, so this migration cannot change who gets notified. Backfilling from the current global columns would freeze every account at today's routing and silently break future changes to those columns. Table-existence check — safe to re-run.
|
||
|
||
**The rest of phase51 needs no migration.** Customer Director / Customer Inspector is a **label-only** rename over the existing `customer` and `external_inspector` ENUM values (§5), so no ENUM change and no user row is touched. `downgrade()` drops the table, discarding every override and returning all accounts to global routing.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
#### phase50 — modern design becomes the default
|
||
|
||
Revision id `phase50_default_modern`. Promotes the phase48 A/B-test design to the default. Two changes, **both required**: the `users.ui_theme` column default becomes `'modern'` (new accounts), and existing rows are moved `'classic'` → `'modern'`. phase48 stored a literal `'classic'` for everyone rather than NULL, so a default change alone would leave every current user on the old design.
|
||
|
||
**It overwrites a deliberate choice** — phase48 gave no way to tell "I picked classic" from "I never touched it", so anyone who actively preferred classic is moved too. They can switch back from the account menu and that choice then sticks; the switcher is unchanged. `/ui/theme-votes` reads the same column, so the tally reads 100% modern afterwards — capture it first if the numbers matter. `downgrade()` returns *everyone* to classic (individual prior choices were never recorded).
|
||
|
||
To change the default without touching saved preferences, set `DEFAULT_UI_THEME=classic` instead of running this.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
#### phase49 — External Inspector role
|
||
|
||
Revision id `phase49_external_inspector` (file `phase49_external_inspector_role.py`, down_revision `phase48_user_ui_theme` — note phase48, the design A/B test, is the real head, NOT phase47). Adds `external_inspector` to the `users.role` ENUM. **Pure ENUM expansion** (adds a value, migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `external_inspector` rows to `inspector` first, which preserves their `InspectorAssignment` scoping exactly.
|
||
|
||
**No matrix rows are seeded.** `MATRIX_DEFAULTS` mirrors every `('<event>', 'inspector')` default into `('<event>', 'external_inspector')` at import time, and `is_enabled()` falls back to that default when a row is absent — so an un-seeded install behaves identically to the Inspector column until an admin saves the matrix page.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade # expands users.role ENUM with 'external_inspector'
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
```
|
||
|
||
#### phase47 — scheduled inspection receipt acknowledgement
|
||
|
||
Revision id `phase47_sched_acknowledged` (file `phase47_sched_acknowledged.py`, down_revision `phase46_followup_req_by`). Adds `scheduled_inspections.acknowledged_at DATETIME NULL` — when the assigned inspector confirms they received the scheduled request. Backs the **receipt acknowledgement** feature — see §5 `ScheduledInspection` "Receipt acknowledgement". **No backfill**: legacy rows keep NULL and render as "Awaiting" confirmation, the correct initial state. `INFORMATION_SCHEMA` column check — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
#### phase46 — follow-up request attribution
|
||
|
||
Revision id `phase46_followup_req_by` (file `phase46_followup_requested_by.py`, down_revision `phase45_sched_parent_insp`). Adds `inspections.follow_up_requested_by INT NULL` (FK → `users.id` ON DELETE SET NULL) and `follow_up_requested_at DATETIME NULL`. Backs **customer-requested follow-ups** — see §11 "Customer-requested follow-up". **No backfill**: legacy rows keep NULL and render as an unattributed follow-up exactly as before.
|
||
|
||
**Breaking detail:** this is the *second* FK from `inspections` to `users`, which made `User.inspections` ambiguous at mapper-configure time (`AmbiguousForeignKeysError` on the first ORM use, not at import). `User.inspections` now declares `foreign_keys='Inspection.inspector_id'` — it means "inspections I performed". Any future FK from `inspections` to `users` needs the same treatment.
|
||
|
||
`INFORMATION_SCHEMA` column + constraint checks — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
#### phase44 — scheduled inspection end date
|
||
|
||
Revision id `phase44_sched_end_date` (file `phase44_scheduled_end_date.py`, down_revision `phase43_sched_recurrence`). Adds `scheduled_inspections.end_date DATE NULL` — the last date a recurring schedule may produce an occurrence. **No backfill**: NULL means "repeat indefinitely", which is exactly what every existing row does today, so nothing changes cadence on deploy. Splits the two meanings `next_due_date` was carrying (see §5 `ScheduledInspection` and rule 85). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
|
||
|
||
### phase43_sched_recurrence
|
||
|
||
Revision id `phase43_sched_recurrence`. Adds the five nullable recurrence-detail columns to `scheduled_inspections` (`weekdays`, `month_mode`, `day_of_month`, `nth_week`, `nth_weekday`) so weekly schedules can name their weekdays and monthly schedules can use either a day-of-month or an nth-weekday rule — see §5 `ScheduledInspection`. **No backfill**: existing rows keep NULLs and retain their current cadence. `month_mode` is VARCHAR, not ENUM, so a future recurrence style needs no 3-step ENUM migration (rule 3). `INFORMATION_SCHEMA` column-existence checks — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase21_performance_indexes
|
||
|
||
Adds four composite indexes covering the highest-traffic multi-column query patterns: `(facility_id, inspection_date)` and `(inspector_id, inspection_date)` and `(status, inspection_date)` on `inspections`; `(facility_id, status)` on `issues`. All single-column indexes already exist from phase12. Uses `INFORMATION_SCHEMA.STATISTICS` existence check — safe to re-run.
|
||
|
||
**Deploy order for phase21:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase21_template_active
|
||
|
||
Adds `active` boolean column to `inspection_templates` so templates can be deactivated without deletion. Inactive templates are hidden from the inspection-start form but remain accessible in the template management UI. Uses `INFORMATION_SCHEMA` column existence check — safe to re-run.
|
||
|
||
### phase23_support_tickets
|
||
|
||
Creates `support_tickets` and `support_ticket_replies` tables. Uses table existence check — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
pip install groq # if not already installed
|
||
# Set GROQ_API_KEY in environment / systemd unit
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase24_notify_defaults
|
||
|
||
Data-only migration. Sets `enabled=True` for `('issue_created', 'admin')` and `('issue_created', 'director')` rows in `notification_matrix` if they already exist. Rows that do not yet exist are seeded at runtime by `MATRIX_DEFAULTS`. No schema change — no existence check needed, `UPDATE` on missing rows is a no-op.
|
||
|
||
### phase25_inspection_gps
|
||
|
||
Adds `submit_latitude DECIMAL(10,7) NULL` and `submit_longitude DECIMAL(10,7) NULL` to `inspections`. Populated at submit time — by browser Geolocation API (web) or CoreLocation (iPad). Null for all existing rows. Uses `INFORMATION_SCHEMA` column existence check — safe to re-run.
|
||
|
||
`inspections/view.html` shows a Google Maps embed (admin/director only) when both columns are non-null.
|
||
|
||
**iPad behaviour:** `InspectionLocationManager` begins acquiring a fix when the submit confirm dialog appears. GPS is captured into `LocalInspection.submitLatitude`/`submitLongitude` and sent in the `POST /api/v1/inspections` body. The `PATCH` endpoint does not accept GPS — creation-time capture only.
|
||
|
||
### phase26_issue_vendor
|
||
|
||
Adds three nullable columns to `issues`:
|
||
|
||
| Column | Type | Purpose |
|
||
|---|---|---|
|
||
| `vendor_name` | `VARCHAR(100)` | External contractor or vendor name |
|
||
| `vendor_contact` | `VARCHAR(200)` | Phone or email for the vendor |
|
||
| `vendor_notes` | `TEXT` | Notes about what the vendor is handling |
|
||
|
||
Displayed in `issues/view.html` and editable via `IssueForm` (`form.html`). Staff-only — not exposed in mobile API. Uses `INFORMATION_SCHEMA` existence check — safe to re-run.
|
||
|
||
### phase27_score_alerts
|
||
|
||
Creates `facility_score_alerts` table. Used by `send_score_alerts()` in `sla.py` for 24-hour deduplication of score-drop notifications. Uses table existence check — safe to re-run.
|
||
|
||
### phase28_fix_inspection_notify
|
||
|
||
Data-only migration. Resets the `notification_matrix` rows for `('inspection_completed', 'director')`, `('inspection_completed', 'admin')`, and `('inspection_completed', 'customer')` to `enabled=True`, matching `MATRIX_DEFAULTS`. These had been inadvertently disabled (likely via an accidental checkbox save on the matrix page), suppressing inspection-completed notifications from both web and mobile API. No schema change.
|
||
|
||
### phase29_broadcasts
|
||
|
||
Creates the `broadcasts` table backing the admin broadcast feature (see §5 `Broadcast` and §7 `broadcast` blueprint). Uses table existence check — safe to re-run.
|
||
|
||
### phase30–32_device_registry (consolidation — read as a unit)
|
||
|
||
These three migrations are the history of a **false start** in device tracking. Net effect after all three: the app tracks devices exclusively in **`api_device_tokens`** (`DeviceToken` model); the short-lived `device_registrations` table and its `DeviceRegistration` model no longer exist.
|
||
|
||
- **phase30_device_registry** — created a separate `device_registrations` table (the abandoned approach).
|
||
- **phase31_device_registry** — drops `device_registrations` (`DROP TABLE IF EXISTS`).
|
||
- **phase32_device_token_columns** — the live path. Adds `ios_version VARCHAR(20)` and `last_seen_at DATETIME` to `api_device_tokens` (phase31's ALTERs were recorded-but-never-executed, so phase32 re-applies them via `INFORMATION_SCHEMA` existence checks) and drops the orphaned `device_registrations` table if still present. Safe to re-run.
|
||
|
||
**The dead `DeviceRegistration` model, `app/api/devices.py` endpoint, and `api_devices` blueprint were removed (July 2026).** They defined a *second* `POST /api/v1/devices/register` that was shadowed at routing time by the `api_auth` copy and would have crashed anyway (it queried the dropped `device_registrations` table). Device registration now has a single implementation: `register_device()` in `app/api/auth.py`, writing to `api_device_tokens`. Do not reintroduce a competing device model or a duplicate register route.
|
||
|
||
### phase33_contract_recipients
|
||
|
||
Creates the `contract_notification_recipients` table backing **per-contract additional notification recipients** (see §5 `ContractNotificationRecipient` and §11). Uses table existence check — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase34_facility_qr
|
||
|
||
Revision id `phase34_facility_qr` (file `phase34_facility_public_token.py`). Adds `facilities.public_token VARCHAR(48)` (unguessable, unique) and **backfills a token for every existing facility** in the migration body, then creates the `uq_facility_public_token` unique index. Backs the public QR landing pages (see §5 `Facility.public_token` and the Public Facility QR section in §7). Uses `INFORMATION_SCHEMA` checks — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
pip install qrcode # new dependency (Pillow already present)
|
||
flask db upgrade # adds + backfills public_token
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase35_issue_handler
|
||
|
||
Revision id `phase35_issue_handler` (file `phase35_issue_handler_type.py`). Adds to `issues`: `handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal'` and `facility_handler_name/contact/notes`. **Backfills** existing rows with a non-empty `vendor_name` to `handler_type='vendor'`. Separates WHO handles an issue (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` checks — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase36_scheduled_insp
|
||
|
||
Revision id `phase36_scheduled_insp` (file `phase36_scheduled_inspections.py`). Creates `scheduled_inspections` (planned/recurring inspection assignments) and adds `inspections.scheduled_inspection_id` (FK → scheduled_inspections, SET NULL) that links a completed inspection back to the schedule that prompted it. See §5 `ScheduledInspection` and the Scheduled Inspections notes in §7/§11. `INFORMATION_SCHEMA` checks — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
# Add to cron (reminders — advance/due to inspector, overdue to admin/director):
|
||
# */30 * * * * curl -s -X POST https://yourdomain.com/scheduled-inspections/run \
|
||
# -d "token=YOUR_DIGEST_SECRET"
|
||
```
|
||
|
||
### phase37_support_chat
|
||
|
||
Revision id `phase37_support_chat`. Creates `support_chat_sessions` + `support_chat_messages` so the customer AI support-chat is persisted (see §5 and §18 "Support Chat"). Table existence checks — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase38_support_knowledge
|
||
|
||
Revision id `phase38_support_knowledge`. Creates `support_knowledge` (admin-curated AI-chat knowledge entries). Active entries are injected into the chatbot system prompt at request time by `_system_prompt_with_kb()` (soft-capped at `_KB_MAX_CHARS`). Table existence check — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase39_area_public_token
|
||
|
||
Revision id `phase39_area_public_token`. Adds `areas.public_token VARCHAR(48)` (unguessable, unique), **backfills a token for every existing area** in the migration body, then creates the `uq_area_public_token` unique index. Backs the per-area public QR landing pages (see §5 `Area.public_token` and the `public` / `facilities` blueprint rows in §7). Mirrors phase34 exactly, one level down (area instead of facility). `INFORMATION_SCHEMA` checks — safe to re-run. No new dependency (`qrcode` + `Pillow` already present from phase34).
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade # adds + backfills areas.public_token
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase41_internal_handler
|
||
|
||
Revision id `phase41_internal_handler` (file `phase41_internal_handler_name.py`). Adds `issues.internal_handler_name VARCHAR(100) NULL` — the free-text janitorial staff member's name used when `handler_type == 'internal'` (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase42_internal_contact
|
||
|
||
Revision id `phase42_internal_contact` (file `phase42_internal_handler_contact.py`). Adds `issues.internal_handler_contact VARCHAR(200) NULL` — phone/email for the janitorial staff member handling the issue when `handler_type == 'internal'`; parallels `internal_handler_name`. `INFORMATION_SCHEMA` column-existence check — safe to re-run.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### phase40_auditor_role
|
||
|
||
Revision id `phase40_auditor_role`. Adds the `auditor` value to the `users.role` ENUM (`ALTER TABLE users MODIFY COLUMN role ENUM(...,'auditor') NOT NULL`). This is a **pure ENUM expansion** (adds a value, removes/migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `auditor` rows to `project_manager` before contracting the ENUM. Backs the new Auditor role — see the `auditor` note in §5 and the `@issue_manager_required` decorator in §6.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade # expands users.role ENUM with 'auditor'
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
**Deploy order for phases 24–32:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
# Add to cron:
|
||
# 0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \
|
||
# -d "token=YOUR_DIGEST_SECRET"
|
||
```
|
||
|
||
### phase22_comment_visibility
|
||
|
||
Adds `is_customer_visible BOOLEAN NOT NULL DEFAULT FALSE` to `issue_comments`. Existing comments default to staff-only visibility. Uses `INFORMATION_SCHEMA` column existence check — safe to re-run.
|
||
|
||
### phase19_issue_mobile_photos
|
||
|
||
Adds `mobile_photo_paths JSON NULL` to `issues` table. Stores extra evidence photos submitted from the iPad at issue-creation time, separate from `result_photos` (resolution photos) so they appear under "Photo Evidence" on the web. Uses `INFORMATION_SCHEMA` existence check — safe to re-run.
|
||
|
||
**Deploy order for phase19:**
|
||
```bash
|
||
flask db upgrade # add mobile_photo_paths column
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### MySQL ENUM Change Protocol (3 steps — always follow)
|
||
```sql
|
||
-- 1. Expand
|
||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
|
||
-- 2. Migrate
|
||
UPDATE users SET role = 'director' WHERE role = 'supervisor';
|
||
-- 3. Contract
|
||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
|
||
```
|
||
|
||
### MySQL Compatibility Rules
|
||
|
||
- **Revision ids must be ≤ 32 characters.** Alembic's `alembic_version.version_num` column is `VARCHAR(32)`. A longer `revision = '...'` value passes `flask db upgrade`'s DDL step but fails when Alembic writes the version row (`Data too long for column 'version_num'`), often leaving the schema changed but the version un-recorded. The *filename* may be longer (e.g. `phase24_issue_created_notify_defaults.py`), but the `revision` id inside must be short (`phase24_notify_defaults`). Count before committing a new migration.
|
||
- **`CREATE INDEX IF NOT EXISTS`** — not supported on MySQL < 8.0.12. Always use `INFORMATION_SCHEMA.STATISTICS` check first.
|
||
- **`batch_alter_table`** — SQLite-only workaround; do not use for MySQL migrations.
|
||
- **Migration deploy order:** Always run `flask db upgrade` before swapping `app/__init__.py` if the new version imports models that reference the new columns.
|
||
|
||
### Deprecated SQLAlchemy Patterns
|
||
```python
|
||
# WRONG
|
||
Model.query.get(id)
|
||
|
||
# CORRECT
|
||
obj = db.session.get(Model, id)
|
||
if obj is None: abort(404)
|
||
```
|
||
|
||
---
|
||
|
||
## 18. Frontend Conventions
|
||
|
||
### Active Nav Tab
|
||
Detected via `request.endpoint.startswith('<blueprint>.')` in each nav `<a>` tag.
|
||
|
||
### Admin Nav Dropdown
|
||
Admin-only tools (Users, Audit Trail, Notification Matrix, Broadcast, Devices) are consolidated into a single **Admin** dropdown in `base.html` (plain text items, no icons, no dividers — matching sibling top-level nav links). The dropdown toggle shows `active` when any of its endpoints is active (`admin_active` flag). The separate user-menu dropdown keeps its own dividers — don't `replace_all` on `dropdown-divider` across the file.
|
||
|
||
### Display Names
|
||
Always use `user.display_name` in templates — never `.username` for display purposes.
|
||
|
||
### Status Label Map
|
||
| DB value | Displayed as |
|
||
|---|---|
|
||
| `completed` | **Submitted** |
|
||
| `in_progress` | In Progress |
|
||
| `flagged` | Flagged |
|
||
| `open` | Open |
|
||
| `resolved` | Resolved |
|
||
| `pending_verification` | Pending Verification |
|
||
|
||
### Forms
|
||
- Flask-WTF CSRF auto-applied to all web forms
|
||
- **Never nest `<form>` tags** — browsers silently discard inner forms
|
||
|
||
### Real-Time
|
||
**SSE banned.** All "live" updates use polling.
|
||
|
||
### Issue Photo Evidence Display (view.html)
|
||
|
||
`view.html` shows `photo_path` and `mobile_photo_paths` together under the **"Photo Evidence"** heading using a `d-flex flex-wrap gap-2` grid. `result_photos` (resolution photos) appear separately under **"Resolution Details"**. Do not merge these sections — they have different semantic meaning.
|
||
|
||
### Contract → Facility Cascade (filter bars and create form)
|
||
|
||
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
|
||
|
||
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar), `scheduled_inspections/form.html` (create/edit — Contract selector narrows the facility list; `selected_project_id` restores it on edit and on POST error).
|
||
|
||
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
|
||
|
||
**Customer role — contract filter scoping:** In `inspections.index()` and `issues.index()`, the `projects` list passed to the template is scoped to contracts the customer is assigned to via `CustomerAssignment`. Non-customer roles still receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
||
|
||
### Customer Dashboard — "Your Facilities" Panel
|
||
|
||
Rendered in `dashboard.html` for `current_user.role == 'customer'`. Uses a Bootstrap card grid instead of a table:
|
||
|
||
- **Grid:** `col-12 col-sm-6 col-lg-4` — 3 per row on large, 2 on medium, 1 on small.
|
||
- **Collapse (> 9 facilities):** First 9 cards are shown; a "Show all N facilities" toggle reveals the rest. Controlled by inline JS (`toggleFacilities` button, `VISIBLE = 9` constant).
|
||
- **Live search (> 6 facilities):** A `#facilitySearch` text input filters `.facility-col` cards in real time by matching against the card's full text content. The show-more bar hides while a search query is active.
|
||
- **Count badge:** The card header always shows the total facility count as a `badge bg-secondary rounded-pill`.
|
||
- The JS block is only emitted when `customer_facilities|length > 9`; the search input is only emitted when `customer_facilities|length > 6`.
|
||
|
||
### Issue Comments — Visibility & Authorship
|
||
|
||
- Comments live in the left column of `issues/view.html` under the issue description, rendered as chat bubbles.
|
||
- Each bubble shows: colored avatar circle (color keyed to `author.id % 7`), display name, role badge (Staff / Customer), `is_customer_visible` badge (staff-only view), status-at-time badge, timestamp, and body.
|
||
- **Staff commenting:** A hidden checkbox `name="is_customer_visible"` in the Add Comment form defaults to unchecked (staff-only). Checking it marks the comment visible to customers.
|
||
- **Customer commenting:** Only shown when `can_customer_comment = is_following or issue.reported_by == current_user.id`. Customer POST bypasses `IssueUpdateForm`; the route sets `is_customer_visible=True` unconditionally.
|
||
- **Read filtering:** `GET issues/view` passes `filter_by(is_customer_visible=True)` to customers; staff receive all comments. **Gated by `COMMENTS_VISIBLE_TO_ALL` (temporary, Aug 2026)** — while that config is true the filter is skipped entirely and customers see every comment. The route passes `comments_open` to the template, which then (a) suppresses the per-comment "Customer visible" / "Staff only" badges, since they would misstate what the customer can actually see, and (b) hides the "Share with customer" tick behind a warning banner reading *"Comments are currently visible to everyone… Do not post internal-only notes here."*
|
||
|
||
**Both are gated on `viewer_is_our_staff` (Aug 2026), not on `role != 'customer'`.** `can_edit` is true for a **Customer Inspector assigned to the issue**, so that account was being shown the staff comment form complete with an internal-process warning and the internal visibility badges. Neither means anything to a customer — nothing they write was ever private — and both expose how we work. The comment box itself is unaffected; only the internal chrome is hidden.
|
||
|
||
`viewer_is_our_staff` is one `{% set %}` at the top of the template, written as an explicit **allowlist** — `current_user.role in ['admin','director','project_manager','auditor','inspector']` — for two reasons:
|
||
|
||
- **It fails closed.** The obvious form, `not current_user.is_customer_account`, fails **open**: if that attribute is missing for any reason — most realistically a process still running an older `models/user.py` while templates have already reloaded — Jinja yields `Undefined`, `not Undefined` is true, and the internal text renders for exactly the accounts it must be hidden from. This was observed in practice. An allowlist of literal role strings can only be true for a role actually listed.
|
||
- **`external_inspector` is absent on purpose, and this is NOT a rule-87 violation.** Rule 87 governs capability and scoping checks, where a Customer Inspector must behave exactly like our own inspector. This asks a different question — *does this person work for us?* — which is the one place the two roles genuinely differ. Do not add `external_inspector` to this list. The checkbox value is still posted and stored, so flipping the config back restores both the filtering and the badges immediately.
|
||
|
||
### List filter preservation (Aug 2026)
|
||
|
||
Filtering a list, then editing or deleting a row, used to dump the user back on the **unfiltered** index. Every list-page action now round-trips the list URL.
|
||
|
||
**The mechanism, end to end:**
|
||
1. `current_url()` — Jinja global registered in `app/__init__.py`, returns `request.full_path` with a bare trailing `?` stripped. The list templates put it in a `<input type="hidden" name="next">` on every action form, and append `?next=` to every link into a detail page.
|
||
2. The detail templates (`issues/view.html`, `inspections/view.html`) set `{% set back_url = request.args.get('next') or url_for('<bp>.index') %}` once, and thread it into their own action forms **and** the Back button.
|
||
3. `return_url(fallback)` (§8) resolves it after the action; `_view_url(id)` in each blueprint re-attaches `next` when an action redirects back to the detail page, so the chain survives an update.
|
||
|
||
**Carry the whole URL, not the filters.** The old unfollow form rebuilt `next` with an 11-argument `url_for(...)` that had to be hand-edited whenever a filter was added — and silently dropped any filter nobody remembered. `current_url()` cannot drift.
|
||
|
||
`safe_redirect_url` still guards every hop, so a crafted `next=https://evil.com` falls back to the index (rule 15) — verified.
|
||
|
||
The inspections list also keeps its older `sessionStorage['insp_list_back_url']` fallback for links created before `next` existed, but a server-provided `next` **wins**: `view.html` emits `var hasNext = true;` and skips the sessionStorage read, otherwise a stale stored URL would override the list the page was actually opened from.
|
||
|
||
### Bulk actions on the list pages (Aug 2026)
|
||
|
||
Both list pages carry a bulk toolbar above the table with per-row checkboxes.
|
||
|
||
| Page | Actions | Permission |
|
||
|---|---|---|
|
||
| Issues (`POST /issues/bulk`) | assign, set status, verify & close, delete | assign/status/verify: admin/director/auditor · delete: admin/director |
|
||
| Inspections (`POST /inspections/bulk`) | export selected to PDF, request follow-up (shared note), clear follow-up, delete | export: anyone who can see the list · rest: admin/director |
|
||
|
||
**The toolbar form sits OUTSIDE the table — this is load-bearing (rule 91).** Row checkboxes join it with the HTML5 `form="issuesBulkForm"` attribute rather than being wrapped by it. Wrapping the table would nest the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row actions would stop working with no error anywhere.
|
||
|
||
**Shared partials, not four copies.** Both lists have classic *and* modern variants, so the markup lives in `templates/partials/bulk_issues_toolbar.html`, `bulk_inspections_toolbar.html` and `bulk_select_js.html`; each of the four list templates includes them. The JS is generic (`.bulk-check`, `.bulk-check-all`, `.bulk-count`, `[data-bulk-action]`, `[data-bulk-confirm]`) and supports shift-click range selection; it disables the action buttons while nothing is selected, so an empty POST can't cost a page round trip.
|
||
|
||
**Partial-failure policy: act, skip, report exact counts** — never block the batch on one ineligible row, never silently drop rows. `_flash_bulk()` in each blueprint emits the one message shape ("3 issues verified and closed. 2 skipped (not awaiting verification)."). Rows are skipped when the action does not apply (already in that status, not submitted yet, already flagged); *permission* is checked per action, up front, not per row.
|
||
|
||
**The inspections bulk route re-applies facility scope to the submitted ids.** The list only ever shows in-scope rows, but the id list arrives in the POST body and is not trusted — without the re-check a crafted request could name any inspection in the system. Issue bulk actions are all manager-level (org-wide access), so they have no per-row scope question.
|
||
|
||
**Deletes remove DB rows first, files second** (both blueprints). An orphaned file is recoverable; a file deleted out from under a surviving row is not. `_collect_inspection_photos()` was factored out of the single-delete path so bulk and single delete cannot drift — a miss there leaks storage silently, forever.
|
||
|
||
### Inspection List Filters
|
||
|
||
`inspections.index()` accepts five additional query params: `date_from`, `date_to` (ISO date strings), `score_min`, `score_max` (0–100 floats), `inspector_id` (int). Inspector filter is suppressed when the viewer has the `inspector` role (they always see their own only). The `inspectors` variable is passed to the template only for non-inspector roles so the dropdown is conditionally rendered.
|
||
|
||
### Inspection List — PDF Export & Filter State Preservation
|
||
|
||
`GET /inspections/export-list-pdf` — same filter logic as `index()`, passes current filters as `filter_summary` string to `generate_inspections_list_pdf()`. Logs `ACTION_EXPORT`.
|
||
|
||
**Filter state on back-navigation:** `list.html` adds class `insp-list-link` to every View/Continue button. On click, JS saves `window.location.href` to `sessionStorage['insp_list_back_url']`. `view.html` reads this key on load and updates the back button `href` so returning from a detail view restores the previous filter state.
|
||
|
||
### Issues List — ID Filter, Date Filter & PDF Export
|
||
|
||
`issues.index()` accepts additional query params: `issue_id` (exact match on `Issue.id`), `date_from`, `date_to` (ISO date strings applied to `Issue.reported_at`; `date_to` expanded to `23:59:59`), `handler_type` (`internal`/`facility`/`vendor`), and `unassigned` (truthy → `Issue.assigned_to IS NULL`). `handler_type` and `unassigned` are both threaded through the pagination links and shared with `export_list_pdf()`.
|
||
|
||
`GET /issues/export-list-pdf` — same scope + filter logic as `index()`, applies SLA post-filter for `?sla=` param (SLA is computed in Python, not stored). Calls `generate_issues_list_pdf()`.
|
||
|
||
Both `index()` and `export_list_pdf()` carry `date_from` / `date_to` in pagination links and the unfollow-next URL.
|
||
|
||
### Inspector Performance — Excel Export
|
||
|
||
`GET /reports/export/inspector-performance` generates a `.xlsx` with two sheets:
|
||
- **Performance Summary** — all inspector KPIs, color-coded cells, totals row
|
||
- **Inspection Detail** — individual inspection records for the period
|
||
|
||
Accepts `start`, `end`, `inspector_id` query params. Logs an `EXPORT` audit action. Uses `openpyxl`.
|
||
|
||
### Dashboard — Grouped Sections
|
||
|
||
The dashboard cards are organised into two labelled sections separated by a divider rule:
|
||
|
||
**Inspections section** (all roles see first 2; staff see all 4):
|
||
1. Today's Inspections — links to `inspections.index` filtered by today
|
||
2. Submitted Today — links to `inspections.index` with `status=completed` + today's date
|
||
3. Stale In-Progress — inspections with `status=in_progress` AND `inspection_date < now - 24h`; links to `inspections.index?status=in_progress`
|
||
4. Pending Follow-ups — inspections with `follow_up_required=True`; links to `inspections.index?status=follow_up`
|
||
|
||
**Issues section** (customers see first 3; staff see all 5):
|
||
1. Open Issues — **split into THREE cards** (all roles), one per handler from `handler_breakdown`: **Open · Janitorial** (`handler_type=internal`, bg-primary), **Open · Facility Staff** (`handler_type=facility`, bg-info), **Open · Vendor** (`handler_type=vendor`, bg-warning). Each is a full clickable card linking to `issues.index?status=open&handler_type=...`. (The single total Open-Issues card with severity C/H/M/L badges was replaced by these three; `severity_breakdown` is still passed but no longer rendered on the dashboard.)
|
||
2. Issues Opened Today — `date_from=today&date_to=today`, with a compact "Handled by" chip split from `opened_today_handler`.
|
||
3. Resolved Today — `status=resolved` + today's date range
|
||
4. Pending Verification — `status=pending_verification`
|
||
5. Unassigned Open — `status=open&unassigned=1`, with a compact "Handled by" chip split from `unassigned_handler`.
|
||
|
||
**"Handled by" splits (phase35+):** the three Open-Issue cards are dedicated cards; cards 2 and 5 use the compact `handler_chips(bd, base)` **Jinja macro** at the top of `dashboard.html` — full-text chips ("Janitorial N / Facility N / Vendor N") linking to `issues.index` with the card's own filter (`base`) plus `handler_type=`. All breakdowns come from `_handler_split(list)` over already-loaded issue lists (`open_issues_all`, `opened_today_all`, `unassigned_all`) — **no extra queries**. Cards with inner links are not wrapped in an outer anchor (nested `<a>` is invalid). `issues.index` gained an **`unassigned=1`** filter (`Issue.assigned_to.is_(None)`), threaded through the list pagination links, so the Unassigned card and its chips link precisely.
|
||
|
||
Each card has a subtitle line (or the handler split) explaining what it counts. Section dividers use `d-flex align-items-center gap-2` with a `<div style="flex:1;height:1px;background:#e2e8f0;">` rule.
|
||
|
||
**Inspector Activity table** follows the cards for admin/director/PM: all active inspectors, today's completed inspection count per inspector, progress bar scaled to `max_count`. Green row highlight if count > 0.
|
||
|
||
### Reports — Navigation & New Pages
|
||
|
||
The **Reports** main-nav item is positioned second (right after Dashboard). **Scheduled Reports** was removed from the main nav and is now a sub-nav tab inside Reports (visible to admin/director/PM).
|
||
|
||
All report pages include `{% include 'reports/_subnav.html' %}` as the first element inside `{% block content %}`. The sub-nav tab visibility is role-gated:
|
||
|
||
| Tab | Roles |
|
||
|---|---|
|
||
| Overview & Trends | All |
|
||
| Issues Aging | All |
|
||
| SLA Compliance | All |
|
||
| Follow-up Closure | admin, director, project_manager |
|
||
| Inspector Performance | admin, director |
|
||
| Scheduled Reports | admin, director, project_manager |
|
||
|
||
### Reports — Overview & Trends: "Avg Score by Facility" Contract filter
|
||
|
||
The **Avg Score by Facility** card (chart) and the **Facility Score Comparison** table on `reports/index.html` share a **Contract** `<select>` (`#scoreContractFilter`) in the chart card header. It is **client-side only**: `reports.index()` attaches `project_id` + `contract` name to each `facility_scores` row and passes `score_contracts` (distinct `(project_id, name)` present, `0`/"No Contract" for unassigned). Selecting a contract filters both the Chart.js bars (`renderFacilityChart(pid)` mutates the existing chart) and the table rows (`.facility-score-row[data-project-id]`); default "All Contracts" shows everything. It does **not** reload the page or affect the top KPIs — only this section. Contracts shown are already role-scoped (customers/inspectors see only theirs).
|
||
|
||
### Reports — Phase R1: Issues Aging (`/reports/issues-aging`)
|
||
|
||
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `1–3 days`, `3–7 days`, `1–4 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
|
||
|
||
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
|
||
|
||
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
|
||
|
||
### Reports — Phase R2: SLA Compliance (`/reports/sla-compliance`)
|
||
|
||
Loads resolved issues in the date range, computes `within_sla()` per issue (compares elapsed hours to `SLA_HOURS[severity]`). Produces:
|
||
- `overall_pct` — org/scope-wide compliance %
|
||
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
|
||
- `by_facility` — list sorted by compliance % descending
|
||
|
||
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
|
||
|
||
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
|
||
|
||
### Reports — Phase R3: Follow-up Closure (`/reports/followup-closure`)
|
||
|
||
`@supervisor_required`. Loads inspections with `follow_up_required=True` in date range. Determines which have been re-inspected via a separate `SELECT parent_inspection_id FROM inspections WHERE parent_inspection_id IN (...)` query — avoids iterating the dynamic `follow_ups` relationship.
|
||
|
||
Annotates each inspection with `insp._has_followup = insp.id in followed_up_ids` (transient Python attribute, not an ORM column).
|
||
|
||
Excel export: `GET /reports/export/followup-closure` — one sheet with color-coded "Followed Up?" column (green/red).
|
||
|
||
### Reports — Phase R4: Customer Facility PDF Summary
|
||
|
||
`GET /reports/facility/<id>/summary-pdf` — available to all roles with facility access (customer scope enforced). Accepts `days` param (30/60/90/180/365; default 90). Calls `generate_facility_summary_pdf()` from `pdf_export.py`. Downloads directly as a PDF attachment.
|
||
|
||
A **PDF Summary** button was added to `reports/scorecard.html` alongside the existing Full Report and period selector buttons.
|
||
|
||
### Support Chat — Customer UX
|
||
|
||
`GET /support/chat` — customer only. Renders:
|
||
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
|
||
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag. **The FAQ section stays visible for the whole session** (phase37) — it is NOT hidden after the first message, and chips are not disabled after clicking (reusable throughout).
|
||
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
|
||
- **Persistence (phase37):** conversations are saved to `support_chat_sessions` / `support_chat_messages`. The page reloads the customer's most recent session into the window (continuity) unless `?new=1`. `let sessionId` (seeded from `chat_session_id`) is sent with each message and updated from the response; the server persists both turns. Header buttons: **History** (`/support/my-conversations`) and **New** (`/support/chat?new=1`). Staff can review any customer's chats read-only at `/support/admin/conversations`. Both detail views `{% include 'support/_transcript.html' %}`.
|
||
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown (the reply is still persisted).
|
||
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
|
||
|
||
### Flag-issue assignee scoping (Aug 2026)
|
||
|
||
The "Assign to" dropdown in the flag-issue offcanvas was built from an **org-wide** query (`role in [director, inspector, external_inspector, ...]`), so a Customer Inspector could assign an issue to anyone in the system — including **another client's** Customer Inspector, who was then emailed the facility name and issue description. A cross-customer data leak, and the same leak in reverse whenever one of our own inspectors picked the wrong name.
|
||
|
||
`_assignable_staff_for(inspection, actor)` in `routes/inspections.py` is now the single source for that list:
|
||
|
||
| Role group | Scope |
|
||
|---|---|
|
||
| `inspector`, `external_inspector` | only those holding an `InspectorAssignment` on **this inspection's contract** — the same rows `get_inspector_scope()` reads, so the offered assignee can always open what they were given |
|
||
| `director`, `project_manager`, `auditor` | org-wide (they hold no `InspectorAssignment`, so contract-scoping would remove them entirely and break escalation) — but offered **only to our own staff** |
|
||
| inactive accounts | never offered |
|
||
|
||
So a **Customer Inspector sees only the inspectors on their own contracts** — their colleagues plus ours — and never our internal org chart. A facility with no contract yields no contract-scoped candidates: fail-closed, leaving "Unassigned" as the only option.
|
||
|
||
**Both call sites must use it.** `execute()` renders the dropdown; `flag_issue()` builds `form.assigned_to.choices`, which is what actually **validates the POST** — that is the security boundary, since the dropdown is only a UI hint. They were previously two hand-maintained queries that had already drifted: the offcanvas offered `project_manager` and `auditor` while the choices rejected them, so picking one **silently discarded the issue** (see below). One helper, one list, no drift.
|
||
|
||
**A failed flag-issue POST now returns 400, not 200.** The offcanvas JS treats `res.ok` as success and reloads the page, so a 200 on a validation failure means the inspector watches the panel close and believes the issue was logged when nothing was saved — rule 60's failure mode, reachable through the PM/auditor drift above and through any rejected assignee. The route returns the re-rendered form with **400** so the JS error branch fires, and flashes a specific message for an out-of-contract assignee rather than "Not a valid choice".
|
||
|
||
### Inspection Execute Page — UX Patterns
|
||
|
||
- **Photo upload-on-select**: `uploadPhotoField(input)` fires immediately on `<input type="file">` change. XHR to `POST /<id>/upload-photo`. On success, the server path is written to `<input type="hidden" id="field_<fid>_server_path">` and a `<img id="thumb_<fid>">` is shown.
|
||
- **Flag-issue as offcanvas**: `#flagIssuePanel` Bootstrap offcanvas contains the flag-issue form. On submit, `saveDraft()` fires first, then the form is sent via `fetch()` FormData, then the page reloads. Never navigates away — photos are never lost.
|
||
- **Auto-save draft**: `setInterval(autoSave, 60000)` calls the save-draft endpoint every 60 s. `#autoSaveStatus` in the footer shows the last-saved timestamp.
|
||
- **Progress indicator**: Counts answered non-zero rating fields vs. total; updates `#progressLabel` in the footer on every change.
|
||
- **Scroll restore**: `window.scrollY` saved to `sessionStorage` on `beforeunload`; restored on `load`.
|
||
|
||
---
|
||
|
||
## 19. Infrastructure
|
||
|
||
### Gunicorn
|
||
```python
|
||
bind = "127.0.0.1:8000"
|
||
workers = multiprocessing.cpu_count() * 2 + 1
|
||
worker_class = "sync"
|
||
timeout = 30
|
||
```
|
||
|
||
### Application Logging
|
||
- `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB)
|
||
- `StreamHandler` → stdout (journalctl)
|
||
|
||
### Nginx
|
||
- `client_max_body_size 50M`
|
||
- Passes `X-Forwarded-For`
|
||
|
||
### ProxyFix (reverse-proxy awareness)
|
||
|
||
`create_app()` wraps `app.wsgi_app` in `werkzeug.middleware.proxy_fix.ProxyFix(x_for=1, x_proto=1, x_host=1)`. Nginx terminates TLS and forwards over loopback, so without this every request's `remote_addr` is `127.0.0.1`. That would collapse all Flask-Limiter keys into one shared bucket (rate limits become global instead of per-client) and make `url_for(_external=True)` emit `http://` links. `x_for=1` trusts exactly one proxy hop (our own Nginx) — do not increase it unless another trusted proxy is added in front, or clients can spoof `X-Forwarded-For` and defeat rate limiting.
|
||
|
||
### Recommended Cron Schedule
|
||
```bash
|
||
0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \
|
||
-d "token=SECRET&frequency=daily"
|
||
*/30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \
|
||
-d "token=SECRET"
|
||
0 3 * * * curl -s -X POST https://your-domain.com/notifications/cleanup-tokens \
|
||
-d "token=SECRET"
|
||
0 8 * * * curl -s -X POST https://your-domain.com/scheduled-reports/run \
|
||
-d "secret=SECRET"
|
||
0 8 * * * curl -s -X POST https://your-domain.com/notifications/check-score-trends \
|
||
-d "token=SECRET"
|
||
*/30 * * * * curl -s -X POST https://your-domain.com/scheduled-inspections/run \
|
||
-d "token=SECRET"
|
||
```
|
||
|
||
---
|
||
|
||
## 20. Known Constraints & Hard Rules
|
||
|
||
| # | Rule | Rationale |
|
||
|---|---|---|
|
||
| 1 | **No SSE** | Exhausted Gunicorn sync worker pool |
|
||
| 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs |
|
||
| 3 | **3-step MySQL ENUM changes** | Skipping causes data loss |
|
||
| 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail |
|
||
| 5 | **`csrf.exempt()` on each child blueprint individually** | `csrf.exempt(api_bp)` does NOT cascade; Flask-WTF checks leaf blueprint object only |
|
||
| 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators |
|
||
| 7 | **Score 0 = unanswered** | Excluded from calculation — not the same as scoring zero |
|
||
| 8 | **12-column grid in PDF** | Must not collapse in print/PDF |
|
||
| 9 | **No nested `<form>` tags** | Browsers silently discard inner forms |
|
||
| 10 | **`log_action()` after `db.session.commit()`** | Entity ID must exist before audit capture |
|
||
| 11 | **`db.session.get(Model, id)` not `Model.query.get(id)`** | SQLAlchemy 2.x deprecation |
|
||
| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement |
|
||
| 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 |
|
||
| 14 | **Email in background thread** | Never block HTTP response |
|
||
| 15 | **Open-redirect guards** | `safe_redirect_url()` in `app/utils/decorators.py` |
|
||
| 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.12** | Use `INFORMATION_SCHEMA.STATISTICS` check |
|
||
| 17 | **`batch_alter_table` is SQLite-only** | Use direct `ALTER TABLE` for MySQL migrations |
|
||
| 18 | **Set `REDIS_URL` in production** | `memory://` is per-process; Gunicorn needs Redis for accurate shared counters |
|
||
| 19 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged |
|
||
| 20 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only |
|
||
| 21 | **`mobile_local_id` idempotency on all mobile write endpoints** | Network retries must not create duplicate records |
|
||
| 22 | **Photo upload before inspection/issue submission** | Server path must be known before the parent record is created |
|
||
| 23 | **Migration deploy before new `app/__init__.py`** | New init imports models referencing new columns; columns must exist first |
|
||
| 24–29 | *(iOS-specific — see iOS CLAUDE.md)* | |
|
||
| 30 | **Do NOT add an explicit `Issue.area` relationship** | `Area.issues` declares `backref='area'`, supplying `Issue.area` automatically. A second declaration raises `ConflictingBackreferences` at startup. |
|
||
| 31 | **Do not sync an issue when its parent `LocalInspection.syncStatus == "failed"`** | Submitting without `inspection_id` creates orphaned server records |
|
||
| 32 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes |
|
||
| 33–38 | *(field ID casting, photo sentinel, notify event_type, follow-up, OperationalError)* | See prior rule entries |
|
||
| 39 | **Inspector issue scope: assigned OR reported — web and API must match** | `issues.index()`, `issues.view()`, and all API issue endpoints (`GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos`) enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role |
|
||
| 40 | **`_issue_payload()` must return all documented fields** | iPad reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. Phase A–E added `result_notes`, `verified_at`, `verification_note`, `reported_by_name`, `area_name`, `assigned_to_name`. Omitting any field silently breaks the corresponding iPad display. |
|
||
| 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row |
|
||
| 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; use `~.any()` which emits `NOT EXISTS` |
|
||
| 43 | **`issues.index()` outerjoin must precede all filters** | Both customer-scope and facility_filter blocks reference `Area.facility_id` |
|
||
| 44 | **iPad evidence photos go to `mobile_photo_paths`, never `result_photos`** | `result_photos` is exclusively for resolution photos added via the web update form. Mixing them causes evidence photos to appear under "Resolution Details" on the web. |
|
||
| 45 | **`PATCH /issues/<id>/photos` is idempotent — merge, never overwrite** | Retry-safe: `merged = existing + [p for p in new_photos if p not in existing]` |
|
||
| 46 | **Facility deduplication in `pullReferenceData()` on iOS** | Server may return same facility ID multiple times; deduplicate before upsert using `seenFacilityIds = Set<Int>()` |
|
||
| 47 | **Magic-byte validation in `_save_photo()`** | Added post-phase-19. Reads 8 bytes before saving; rejects files that do not begin with a known image magic (`\xff\xd8\xff`, `\x89PNG`, `GIF87a`, `GIF89a`). Prevents MIME-type spoofing via extension-only checks. |
|
||
| 48 | **`upload_photo_ajax` endpoint on inspections blueprint** | `POST /<inspection_id>/upload-photo` with `@limiter.limit("30 per minute")`. Triggers on file selection (not form submit) so photos survive AJAX draft-save and page navigation. Stores in `inspection_photos/` subfolder; returns `{ok, path}`. |
|
||
| 49 | **Template schema snapshotted at submit time** | `execute()` POST stores `form_fields` list as `_template_schema` inside `inspection.notes` JSON. `view()` prefers this snapshot over the live template so historical inspection views remain correct if the template changes later. |
|
||
| 50 | **`mobile_local_id` UUID format validation on write endpoints** | `POST /api/v1/inspections` and `POST /api/v1/issues` validate `mobile_local_id` against `_UUID_RE` regex. Rejects non-UUID strings with HTTP 400. Prevents garbage values from being stored as idempotency keys. |
|
||
| 51 | **Security response headers via `@app.after_request`** | Added `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, and a `Content-Security-Policy` (CDN allowlist + `unsafe-inline`). Uses `setdefault` so API responses can override if needed. |
|
||
| 52 | **Inspection `execute.html` offline-resilient photo flow** | Photos are uploaded immediately on file selection via `uploadPhotoField()` (XHR to `upload_photo_ajax`). Server path is stored in `<input type="hidden" id="field_<fid>_server_path">`. AJAX draft-save and flag-issue submission read these hidden fields so photos are never lost on navigation. |
|
||
| 53 | **Flag-issue panel is an offcanvas — not a page navigation** | Converted from a navigate-away flow to a Bootstrap offcanvas. Draft is saved first via `saveDraft()`, then the flag-issue form is submitted via `fetch()` FormData, then the page reloads. Eliminates the entire class of "photos lost on navigation" bugs. |
|
||
| 54 | **Bulk issue verification via `POST /issues/bulk-verify`** | `@supervisor_required`. Accepts `issue_ids` list from form. Skips issues not in `resolved` or `pending_verification` state. Calls `log_action()` after `db.session.commit()` per rule 10. |
|
||
| 55 | **Scheduled "issues" report groups by facility with SLA status** | `_build_report_data()` now produces `issues_by_facility` (list of `(facility_name, [(issue, sla), ...])`) and `sla_breached`/`sla_at_risk` counts alongside the flat `issues` list. CSV builder uses `resolved_facility` (not `area.facility`) to avoid crash when `area_id` is None. |
|
||
| 56 | **Customer role: `POST` to `issues.view` returns 403** | The `view()` route checks `request.method == 'POST'` inside the customer scope block and calls `abort(403)`. Customers have read-only access; the template already hides the update form, but server-side enforcement is required against crafted requests. |
|
||
| 57 | **Inspector contract scoping: `get_inspector_scope()` — strict, no fallback** | Inspectors with NO `InspectorAssignment` rows see nothing (empty list, not `None`). Returns `None` only for non-inspector roles. All routes and API endpoints that currently filter by `inspector_id` or `assigned_to/reported_by` must instead filter by the facility list returned by `get_inspector_scope()`. |
|
||
| 58 | **Inspector scope covers all data in contracted facilities, not just own work** | Facility list, inspection list, issue list — all scoped to contracted facilities. Dashboard personal stats (today's work, avg score, trend) additionally filter by `inspector_id` so the productivity view stays personal. Issues show ALL facility issues, not just assigned ones. |
|
||
| 59 | **`assign_inspector_contracts` route replaces the entire assignment set on POST** | The form sends the full checked list; existing assignments not in the POST body are deleted, new ones are inserted. Callers must always POST the complete desired set, not a diff. The page includes Select All / Deselect All buttons (JS-only, no server round-trip) and a live "N assigned" badge that updates on each checkbox change. |
|
||
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
|
||
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
|
||
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
|
||
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()` and `issues.index()` build the `projects` list differently for `customer` role: query `CustomerAssignment.query.filter_by(user_id=current_user.id)` to get assigned `project_id` values, then filter `Project` to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in `inspections.start()`. |
|
||
| 64 | **Invitation/reset email `From` must be a mail-server-authorized identity; never a bare `noreply@<full-host>`** | Both `_send_invite_email` (`customers.py`) and `_send_password_reset_email` (`auth.py`) take an optional `base_url` (call sites pass `request.host_url`) and build `setup_link`/`reset_link` from `effective_base`, so the **link** always points at the host the user is on. The **`From`** is where deliverability lives: a per-host `noreply@jqc.ltservicesinc.com` (subdomain, wrong local part) was accepted by the relay then silently dropped by SPF/DMARC — reset/invite mail never arrived while notification mail (which used `MAIL_DEFAULT_SENDER`) did. Confirmed July 2026 via SMTP A/B test. **Current behaviour:** _reset password_ sends from the fixed authenticated `MAIL_DEFAULT_SENDER` (fallback `MAIL_USERNAME`); _customer invite_ sends from `branded_sender(effective_base)` (see §8 `mail_utils.py`), a `(display_name, address)` tuple. The **display name** is always per-domain (`BRAND_NAMES`), but the **From address** is branded only for the authenticated domain and any `SENDER_AUTHORIZED_DOMAINS` — all other domains keep the authenticated address so they always deliver. This is the deliverable default: `From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>` with zero DNS. To brand the actual address for another domain, set up its SPF `include` + DKIM, then add it to `SENDER_AUTHORIZED_DOMAINS`. **Never** brand an address for a domain lacking SPF/DKIM (accepted-then-dropped, the failure above), and never reintroduce `noreply@<full-host>`. |
|
||
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
|
||
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
|
||
| 67 | **`display_name` in JS must use `\| tojson`, not inline Jinja interpolation** | `"Hi {{ name }}"` in a JS string literal breaks if `name` contains `"` or `\`. Use `var name = {{ name \| tojson }};` then concatenate. |
|
||
| 68 | **Support ticket customer replies revert status from `answered` → `open`** | When a customer posts a follow-up on an answered ticket, the route sets `ticket.status = 'open'` so admins see it in their open queue. Admin must manually close or re-answer. |
|
||
| 69 | **Customer issue create: `assigned_to` field hidden, `facility_id` scoped to `get_customer_scope()`** | `issues.create()` detects `role == 'customer'`, scopes facilities to the customer's assigned set, sets `staff = []` for the assigned_to dropdown, and hides the field in `form.html`. `IssueForm.facility_id.choices` must still include all active facilities so POST validation passes. |
|
||
| 70 | **`notify()` does NOT commit — caller must `db.session.commit()` after all `notify()` calls** | `notify()` adds a `Notification` row to the session but leaves the commit to the caller. The support helpers (`_notify_admins_new_ticket`, `_notify_customer_reply`, `_notify_admins_customer_reply`) each call `db.session.commit()` after the `notify()` loop. |
|
||
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
|
||
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
|
||
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
|
||
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only aggregate quality data: the overall rating, inspection/issue COUNTS, the score trend, and a **Recent Inspections** list showing each inspection's date + quality **label** (July 2026 — `_recent_rows()` shapes these). `_recent_rows()` deliberately does **not** put the raw score in the payload, so the percentage cannot leak into the rendered page; `_rating_label(None)` yields "Not yet rated" so unscored inspections render safely. Still **never**: raw per-inspection score percentages, issue descriptions, inspector names, checklist/template names, per-checklist-item scores, severity/SLA detail, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
|
||
| 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
|
||
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
|
||
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
|
||
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`, `internal_handler_name`, `internal_handler_contact`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
|
||
| 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. |
|
||
| 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. |
|
||
| 86 | **A second FK from a table to `users` breaks any relationship that didn't pin `foreign_keys`** | Adding `inspections.follow_up_requested_by` (phase46) made `User.inspections` ambiguous — `AmbiguousForeignKeysError`, raised at first ORM *use*, not at import, so the app starts fine and then every request 500s. `User.inspections` now pins `foreign_keys='Inspection.inspector_id'`. Check existing relationships before adding another FK to `users` from a table that already has one. |
|
||
| 83 | **A bad `scheduled_inspection_id` must NEVER fail the inspection submission** | `_resolve_schedule()` in `app/api/inspections.py` drops an unknown or foreign link and logs a warning instead of returning 404/403. The app is offline-first: a completed inspection can sit in the outbox for days, during which the schedule may be deleted, reassigned, or rolled forward. Erroring would burn the 5 sync retries and permanently strand that inspection **and its photos** on the device. A missed fulfil is fixable from the web; a stranded submission is not. The ownership check still refuses to *link* a foreign schedule (one inspector must not fulfil another's) — it just accepts the inspection anyway. |
|
||
| 82 | **A schedule's recurrence columns must be CLEARED when they don't apply to the chosen frequency** | `_apply_recurrence()` in `routes/scheduled_inspections.py` is the single write path for `frequency` + `weekdays`/`month_mode`/`day_of_month`/`nth_week`/`nth_weekday`, and it NULLs the blocks that don't apply. Setting `sched.frequency` directly (as create/edit used to) leaves stale settings behind — a weekly→monthly switch would keep `weekdays` and `recurrence_label` would lie. The hidden form blocks still POST their values, so client-side hiding is not enough. |
|
||
| 84 | **"Instructions" is a LABEL over `notes` — never rename the field, attribute, column or API key** | `ScheduledInspectionForm.notes` renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays `notes` (`api/scheduled.py::_scheduled_payload`), which is what `APIScheduledInspection.notes` decodes into `LocalScheduledInspection.notes`; the iPad exposes it through a computed `instructions` accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is `try?`-decoded, so it would fail to nil rather than throwing. |
|
||
| 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
|
||
| 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. |
|
||
| 88 | **`app/enrollment/` writes no DB row and has exactly ONE read — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. The single permitted model access is `mailer._admin_recipients()` reading active `admin` users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST **create** Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. |
|
||
| 98 | **Never set `display` on a native checkbox or radio to give it a touch target** | `ipad_responsive.css` had `input[type=radio] { min-height: 44px; display: inline-flex }` inside a `(pointer: coarse), (max-width: 1194px)` query. Replacing a radio's intrinsic box with a flex container leaves the glyph painting at ~16px while the element claims 44px, so the visible dot and the hit area stop coinciding and taps land on nothing — the per-account notification matrix looked entirely unclickable because of it. Grow the target with `transform: scale()` + margin, or wrap the input in a `<label>` that fills the cell. |
|
||
| 97 | **The template list's Edit modal (`/rename`) is a THIRD edit path — keep it in sync with create and the form editor** | The modal on the template list posts to `rename_template`, not `edit_template`, so a field added only to the two WTForms pages is invisible to the people who edit templates from the list. It carries a hidden `contracts_present=1` marker: an empty selection with the marker means "make this shared", while a POST without it leaves restrictions untouched — otherwise any other caller of that route would silently share a restricted form with every customer. |
|
||
| 95 | **A template with NO `template_contracts` rows is SHARED, not hidden** | The empty set means "available on every contract" — that is what makes phase52 additive and why it needed no backfill. Reading it the other way would hide every pre-phase52 form from every contract at once. The convention lives in exactly one place, `InspectionTemplate.available_query()`; every picker, the POST validation behind it, and the mobile API call it rather than writing their own filter. A facility with no contract gets shared forms only (fail-closed). |
|
||
| 96 | **An explicit `?project_id=` / `?facility_id=` filter must still be intersected with the caller's own scope** | Accepting a caller-supplied contract filter *instead of* their scope is a leak, not a filter: a Customer Inspector could pass another customer's facility id and get that customer's form names back. `_visible_templates()` returns `[]` for an out-of-scope contract — empty rather than an error, so the endpoint does not confirm the contract exists either. Applies to any future endpoint that takes a scope-shaped query parameter. |
|
||
| 93 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. Two separate queries had already drifted (offcanvas offered project_manager/auditor, choices rejected them), which silently discarded issues. Contract scoping applies to the two inspector roles for EVERY actor, not just customer ones: an org-wide list let anyone assign another client's Customer Inspector, who was then emailed that facility's name and issue description. Never widen this back to an unscoped `User.query.filter(role.in_(...))`. |
|
||
| 94 | **A failed flag-issue POST must return a non-2xx** | The offcanvas JS branches on `res.ok`, so a 200 re-render of the invalid form reads as success: the panel closes, the page reloads, and no issue exists — with nothing in the logs and no message to the user. `flag_issue()` returns 400 on a failed POST for exactly this reason. Any future AJAX-submitted form needs the same treatment (rule 60 is the same failure seen from the other end). |
|
||
| 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). |
|
||
| 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. |
|
||
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, **the customer-facing support surface** (`_is_customer_side()` — both roles get the same door, then branch per role for scope and for the AI's system prompt), and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
|
||
| 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. |
|
||
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
|
||
|
||
---
|
||
|
||
## 21. Change Philosophy
|
||
|
||
1. **Surgical, additive patches** — smallest possible change to achieve the goal
|
||
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise
|
||
3. **Never remove existing functionality** unless explicitly directed
|
||
4. **Log all create/update/delete actions** via `log_action()`
|
||
5. **Migration existence checks** — all migrations safe to re-run
|
||
6. **Full file contents for 1–3 file changes**; deployment map for larger changesets
|
||
7. **Explicit deploy instructions** — migration steps separated from code steps
|
||
8. **Root cause analysis** on errors — never apply temporary workarounds
|
||
---
|
||
|
||
## 22. Object Storage Migration (R2)
|
||
|
||
**Goal:** move photo **files** off the server's local disk (`app/static/uploads/`) to **Cloudflare R2** (S3-compatible, $0 egress) so storage expands without touching the server. The database is NOT the bottleneck — rows are tiny; PDFs are streamed (`BytesIO`), never written to disk. Only photos accumulate.
|
||
|
||
**No schema change.** The DB already stores a relative path string (`uploads/issue_photos/abc.jpg`). That exact string becomes the **object key** in R2, so rows never change and `media_url()` resolves the same key on either backend.
|
||
|
||
**Serving decision:** private bucket + **presigned GET URLs** (TTL 24h). This is *more* private than today (static files are served unauthenticated). `media_url()` is the single seam — switching to a public custom-domain CDN URL later is a one-function change.
|
||
|
||
**Complete photo-path inventory (the migration MUST cover all 5 — missing one loses those photos from the UI even if the file exists):**
|
||
1. `Issue.photo_path` (string)
|
||
2. `Issue.mobile_photo_paths[]` (JSON list — iPad evidence)
|
||
3. `Issue.result_photos[]` (JSON list — web resolution photos)
|
||
4. `Inspection.form_data{}` — any `uploads/...` string (image fields; walk nested)
|
||
5. `InspectionResult.photo_path` (string — per checklist-item result photo)
|
||
- `Inspection` itself has **no** `photo_path` column (the `photo_path` at inspection.py:103 belongs to `InspectionResult`, not `Inspection`).
|
||
- Signatures are inline `data:` base64 in the DB, **not files** → correctly ignored.
|
||
|
||
**Zero-loss invariants (non-negotiable):**
|
||
- Object key **==** the relative path string already in the DB. Never rewrite DB paths.
|
||
- **Copy, never move.** The migration only uploads. Local `static/uploads/` is untouched through cutover and a 30-day safety window.
|
||
- Per-file **MD5 + size verification** (local hash vs R2 `head_object`) — a file counts as migrated only when it matches.
|
||
- Cutover is **gated**: only flip `STORAGE_BACKEND=s3` when `verified == referenced-present` (from the Phase 0 baseline) with zero mismatches.
|
||
- S3 backend **falls back to the local file** if a key is absent (transition safety).
|
||
- **Rollback = one env var** (`STORAGE_BACKEND=local` + restart). Local files never left disk.
|
||
- Magic-byte validation stays in the **caller**, before `storage.save()`.
|
||
- `media_url()` is the ONLY place a photo URL is built server-side — no bare `url_for('static', filename=<photo>)` anywhere after Phase 1.
|
||
|
||
### Phase 0 — Audit (READ-ONLY) — ✅ delivered
|
||
`scripts/audit_photos.py`. Reads all 5 sources + disk, reconciles, writes a JSON baseline. Writes nothing to the DB or uploads tree.
|
||
- [x] Script delivered (`scripts/audit_photos.py`).
|
||
- [ ] Run on production: `python scripts/audit_photos.py --report /home/jqc/photo_audit_baseline.json`
|
||
- [ ] Record the baseline number: **"N photos must still resolve after cutover."**
|
||
- [ ] Investigate any `missing_on_disk` entries — these are PRE-EXISTING broken references, surfaced now so they can't be blamed on the move.
|
||
|
||
### Phase 1 — Storage abstraction + local backend (NO-OP, safe to deploy) — ✅ delivered
|
||
- [x] `app/utils/storage.py` — interface: `save(file_obj, subfolder) -> key`, `read(key) -> bytes`, `delete(key)`, `exists(key) -> bool`, `abs_local_path(key)`, `media_url(key) -> str`. Backend chosen by `STORAGE_BACKEND`, one instance cached in `app.extensions`.
|
||
- [x] `LocalBackend` = byte-for-byte current behavior (`static/uploads` write; `media_url` = `url_for('static', filename=key)`; `abs_local_path` = `static_folder/key`).
|
||
- [x] Config: `STORAGE_BACKEND=local|s3` (default `local`) in `config.py`.
|
||
- [x] Routed `_save_photo()` (inspections.py) + `api/photos.py::upload_photo` through `storage.save()` (magic-byte / ext validation stays in the callers).
|
||
- [x] `media_url` registered as a Jinja global in `app/__init__.py`; swapped all 8 photo render sites: `issues/view.html` (×6: primary, mobile, result), `inspections/view.html` (×1: form image), `inspections/execute.html` (×1).
|
||
- [ ] Deploy with `STORAGE_BACKEND=local` → identical behavior; regression check that web + iPad photos still load and new uploads still save.
|
||
- **Deferred to Phase 2 (not yet routed through storage):** photo *deletes* (issues.py ~1013, inspections.py ~1340) and PDF-export image *reads* (`utils/pdf_export.py`). These still hit local disk directly — fine while `local`, must be routed before the `s3` flip. **Bug to fix when routing:** the inspection-delete cleanup builds `os.path.join(UPLOAD_FOLDER, '..', 'static', rel_path)` which normalizes to `app/static/static/...` (double `static`) and never deletes — routing it through `storage.delete(key)` fixes it. The issue-delete path (`root_path + 'static' + rel_path`) is already correct.
|
||
|
||
### Phase 2 — R2 backend (build, do NOT flip yet)
|
||
**2a — delivered:**
|
||
- [x] `boto3>=1.34` added to `requirements.txt` (imported **lazily** in `S3Backend` — a `local` deploy doesn't need it installed).
|
||
- [x] R2 config keys in `config.py`: `R2_ENDPOINT_URL`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_PRESIGN_TTL` (default 86400), `R2_MEDIA_FALLBACK` (default false).
|
||
- [x] `S3Backend` in `storage.py`: private bucket; `save` → `put_object` (ContentType from ext); `media_url` → presigned GET (TTL); `read` → `get_object`; `exists`/`_head` → `head_object`; `delete` → `delete_object`. Client cached in `app.extensions`, `region_name='auto'`, SigV4.
|
||
- [x] Transition fallback: `read()` falls back to the local copy on a missing object; `media_url()` falls back to a local static URL **only when `R2_MEDIA_FALLBACK=true`** (off by default — avoids a HEAD per image; cutover is gated on full verification anyway).
|
||
- [x] Routed photo **deletes** (issues.py, inspections.py) → `storage.delete(key)`; **fixed** the inspection-delete double-`static` bug in the process.
|
||
|
||
**2b — delivered:**
|
||
- [x] PDF-export image reads routed through storage via `storage.materialize_to_dir(keys)`. Local backend returns the real static folder (no-op); s3 downloads the referenced keys to a temp dir mirroring the `key` layout. The two entry points (`generate_inspection_pdf` — keys from `_collect_media_keys(form_data)`; `generate_issue_pdf` — `photo_path` + `mobile_photo_paths` + `result_photos`) point `static_folder` at it and clean up in `finally`. All render sites (`_form_fields_section`, `_photo_grid`, `_compress_image`) are **unchanged** — they still do `os.path.join(static_folder, key)`, which now resolves under the temp dir on s3. Only the inspection + issue PDFs embed photos; list/scheduled/facility PDFs don't take `static_folder`.
|
||
|
||
**Operator setup (before flip, not code):**
|
||
- [ ] Create R2 bucket (e.g. `jqc-media`), US region; create an R2 API token → Access Key + Secret + endpoint.
|
||
- [ ] `.env`: `R2_ENDPOINT_URL`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET=jqc-media`, `R2_PRESIGN_TTL=86400`. Keep `STORAGE_BACKEND=local` for now.
|
||
- [ ] `pip install boto3` into the venv.
|
||
|
||
### Phase 3 — Verified migration sync — ✅ script delivered
|
||
`scripts/migrate_photos_to_r2.py`. Copy-only (never deletes local, never touches the DB), idempotent, resumable, checksummed. Reads R2 creds from config — run it while still on `STORAGE_BACKEND=local`.
|
||
- [x] Script delivered. Per file: MD5 + size → skip if R2 object already matches (resumable) → else single-part `put_object` (so ETag == MD5) → re-`head_object` and assert ETag + size match. Uploads **every** file under `static/uploads/` (orphans included). JSON log of all failures. Exit 0 only when every file verifies (0 mismatches, 0 errors).
|
||
- [ ] `python scripts/migrate_photos_to_r2.py --dry-run` (review plan).
|
||
- [ ] `python scripts/migrate_photos_to_r2.py` — bulk sync (can run while `local` is live; only writes to R2).
|
||
- [ ] Re-run immediately before cutover to catch the delta (idempotent — skips already-verified).
|
||
- [ ] Gate: script exits 0 → every local file verified in R2. Cross-check `verified >= referenced-present` from the Phase 0 baseline. Only then flip in Phase 4.
|
||
|
||
### Phase 4 — Cutover + iOS
|
||
**4a — server photo URLs — ✅ delivered:**
|
||
- [x] `storage.media_url(key, external=False)` — `external=True` yields an absolute URL for off-origin (iPad) consumers: local backend uses `url_for('static', ..., _external=True)`; s3 returns the (already absolute) presigned URL. Templates call with `external=False` → unchanged relative `/static/` URLs.
|
||
- [x] Issue payload (`api/issues.py` `_issue_payload`) adds `photo_urls` (absolute; order = `[photo_path] + mobile_photo_paths`, matching the iPad's evidence merge) and `result_photo_urls`. Relative keys stay as keys (they're still submission values). Helper `_photo_urls()`.
|
||
- [x] Inspection detail payload (`api/inspections.py`) adds `form_media` = `{field_id: absolute_url}` for `uploads/...` image field values. Helper `_media()`.
|
||
- These are additive — the iPad ignores them until 4b ships, and on `local` they're just absolute static URLs, so nothing breaks.
|
||
|
||
**4b — iOS: consume the URLs — ✅ delivered:**
|
||
- [x] `APIAssignedIssue` gains `photoUrls` + `resultPhotoUrls`; `APIIssueDetail` gains `resultPhotoUrls`; `APIInspectionSummary` gains `formMedia` ({fieldId: url}) + a derived `mediaURLByPath` ({path: url}).
|
||
- [x] `LocalIssue` stores parallel `photoServerUrls` / `resultPhotoServerUrls` (JSON-backed), mapped in `SyncManager.pullAssignedIssues` (both branches) and `IssuesView.refreshStatusFromServer`.
|
||
- [x] `ServerConfig.mediaURL(absolute:path:)` resolver — prefers the absolute URL, falls back to `current + "/static/" + path`. Wired at all display sites: `IssuesView` evidence + result (index-paired with the URL arrays), `InspectionHistoryView` `PhotoThumbnailView` (via a `mediaURLByPath` SwiftUI environment injected on the read-only grid — avoids threading field IDs through the grid).
|
||
- Backward-compatible: empty URL arrays (older server) → resolver builds the `/static/` URL as before.
|
||
|
||
**Operator cutover runbook (after 4b ships + Phase 3 sync exits clean):**
|
||
- [ ] **CSP:** the web `img-src` must allow the R2 host or the browser blocks presigned image loads. Handled in `app/__init__.py` `set_security_headers` — the R2 endpoint host is derived from `R2_ENDPOINT_URL` and appended to `img-src` automatically when configured (local backend unaffected). If you use a custom R2 domain for presigned URLs, add that host too.
|
||
- [ ] Confirm `python scripts/migrate_photos_to_r2.py` prints "✅ SAFE" (0 mismatches) and verified count ≥ Phase 0 baseline present-count.
|
||
- [ ] Maintenance window: run the sync once more (delta) → set `STORAGE_BACKEND=s3` in `.env` → `systemctl restart janitorial-qc`.
|
||
- [ ] Smoke test: existing web issue/inspection photos load; existing iPad issue photos load; a **new** upload from web and from iPad lands in R2 and renders; generate an inspection PDF + issue PDF with photos.
|
||
- [ ] Keep `static/uploads/` for 30 days as backup (untouched). Snapshot, then reclaim disk as a **separate, deliberate** step.
|
||
|
||
### Rollback (any phase after cutover)
|
||
- [ ] `STORAGE_BACKEND=local` → restart. Instant revert; local files were never touched.
|
||
|
||
---
|
||
|
||
## 23. Photo Capture-Time / Geo Overlay
|
||
|
||
**Goal:** evidence photos carry a visible, tamper-evident record of *when* and *where* they were taken. Implemented in `app/utils/photo_stamp.py`, applied in `POST /api/v1/photos/upload`.
|
||
|
||
**Why upload-time and not `PATCH /issues/<id>/photos`** (rule 81): that PATCH only receives path strings — the bytes are already stored and it carries no capture metadata. Stamping there would require a read-modify-write per key, a new overwrite-in-place storage primitive (`storage.save()` mints a new uuid key; §22 requires key == DB path), and would risk a **double burn** on retry since the endpoint is intentionally idempotent (rule 45). At upload the raw bytes and camera EXIF are in hand and exactly one already-stamped object is written.
|
||
|
||
### Metadata resolution order
|
||
1. **Client fields** — `captured_at` (ISO-8601, offsets and `Z` accepted), `latitude`, `longitude` multipart form fields. Preferred: the app is offline-first, so a photo taken at 09:14 may not sync until 16:00 — only the client knows the true capture moment.
|
||
2. **EXIF** — `DateTimeOriginal` → `DateTimeDigitized` → `DateTime`; GPS from the GPS IFD (DMS rationals → signed decimal, honouring N/S/E/W refs).
|
||
3. **Server receipt time** — last resort, no geo.
|
||
|
||
`resolve_metadata()` returns `(dt, lat, lng, source)` where `source` ∈ `client|exif|server`; it is echoed back as `capture_source` in the response and logged, so you can tell how much to trust a given stamp.
|
||
|
||
### Rendering
|
||
- Translucent black bar across the bottom; line 1 `YYYY-MM-DD HH:MM:SS EDT`, line 2 `lat, lng` (omitted when unknown).
|
||
- Font/padding scale off the image's **short edge**, so portrait and landscape look the same. TrueType is probed at the usual Linux/Windows paths with a graceful fall back to Pillow's default.
|
||
- White text with a 1px dark outline stays legible over bright surfaces.
|
||
- **`ImageOps.exif_transpose()` runs before drawing** — the re-encode drops EXIF, so without it an iPhone photo would come out visibly rotated and the bar would land on the wrong edge.
|
||
- JPEG (q88) and PNG are stamped; **GIF and anything else passes through untouched** rather than risking a broken re-encode.
|
||
|
||
### Hard guarantees
|
||
- **Never lose a photo.** Every failure path (corrupt bytes, unsupported format, missing Pillow, font problems) returns the ORIGINAL bytes with `stamped: False` and logs a warning — it never raises.
|
||
- **No storage/schema change.** `stamp_file_storage()` returns a `werkzeug` `FileStorage` with the same filename/content-type, so `storage.save()` derives the same key and both the `local` and `s3` backends work unchanged.
|
||
- Toggle with `PHOTO_STAMP_ENABLED=false` (default `true`) to store raw uploads.
|
||
|
||
### Not covered (deliberate)
|
||
- Web-form uploads (`_save_photo` in `routes/inspections.py`) are **not** stamped — browsers rarely supply reliable capture/GPS metadata. The helper is reusable if that changes.
|
||
- Only the stamped image is stored; no pristine original is retained. Since the burn happens *before* the first write, nothing stored is ever destroyed.
|
||
- EXIF is not re-written into the output (the overlay is the record). Add it here if a machine-readable copy is ever needed.
|
||
|
||
---
|
||
|
||
## 24. Enrollment Form (`/enrollment`)
|
||
|
||
A customer-facing onboarding intake reproducing the printed **JQC Enrollment Form**, held **deliberately apart** from the rest of the application. It is the one feature in the tree that owns its whole vertical slice.
|
||
|
||
```
|
||
app/enrollment/
|
||
├── __init__.py register_enrollment(app) + the separation contract
|
||
├── schema.py the form AS DATA — single source of truth
|
||
├── storage.py JSON-file persistence (no model, no migration)
|
||
├── routes.py public form + admin inbox
|
||
└── templates/enrollment/
|
||
├── form.html standalone public page (no base.html)
|
||
├── submitted.html thank-you + reference number
|
||
├── admin_list.html extends base.html
|
||
└── admin_detail.html extends base.html
|
||
```
|
||
|
||
### Separation contract — keep this true
|
||
|
||
1. **No `app.models` import, nothing written to the database.** Enrollment happens *before* any contract, facility or user exists, so there is nothing to key a row against. Deleting the package would remove the routes and nothing else.
|
||
2. No migration, no model, no notification-matrix event, no API/iPad surface.
|
||
3. Its own `template_folder` — enrollment markup never mixes into `app/templates/`.
|
||
4. The only shared code it uses is what it should not reinvent: the app factory, Flask-WTF CSRF, the rate limiter, `@admin_required`.
|
||
|
||
If it ever needs to *create* the accounts it describes, do that as a **separate explicit admin action** that reads a stored submission. Do not let the public form reach into the models.
|
||
|
||
### Form flow (people first, then the matrix)
|
||
|
||
The printed form had six fixed seats (Admin/Director + Inspector 1–5) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that:
|
||
|
||
The header collects Project Name, **Request by**, **Requester email** (required — the confirmation goes there) and Date Requested. The printed sheet's blank *"for office use"* block is **not rendered on the web form** — a customer cannot fill it in; those fields still exist and are filled by staff on the admin detail page.
|
||
|
||
1. **Step 1 — the people.** Free-form rows, each with a **role dropdown**, name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE` (`director`); **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed.
|
||
|
||
**`schema.ROLES` offers exactly two roles — Director and Inspector (Phase 51).** Enrollment describes *customer-side* people only; our own staff (admin, auditor, internal inspector) are created in User Management and were never really enrollable. `schema.APP_ROLE_FOR` maps the seat to the app role an admin creates later — `director` → `customer` (Customer Director), `inspector` → `external_inspector` (Customer Inspector). It is a plain string map: the package still imports nothing from `app.models` (rule 88).
|
||
|
||
**`ADMIN_ROLES` keeps `admin` and `auditor` even though neither is selectable** — legacy tolerance. Submissions taken before Phase 51 stored those roles, and dropping them from the set would silently re-render their admin-only task cells (ref 10) as `n/a` in the admin detail view and the CSV export. Selectable roles shrink; the ability to read back what was already recorded does not. (`routes.py` already coerces an unrecognised posted role to `DEFAULT_FIRST_ROLE`, so the narrower dropdown needs no parser change.)
|
||
2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name).
|
||
3. **Step 3 — mobile app**, likewise one column per person.
|
||
|
||
A **"Recommendation selection"** button applies `schema.recommendation_map()` per person's role — admin-side roles get the Admin/Director column of the old table, inspector roles the Inspectors column — after which any box can be changed. The RECOMMENDATION table itself is **no longer rendered**; `schema.RECOMMENDATION` remains the authority behind the button. The button deliberately leaves **Step 3 alone** — who carries a tablet is not something a preset can guess.
|
||
|
||
**Field naming — the client index and the stored key are independent.** The browser names fields `person_<n>_*`, `task_<ref>_person_<n>`, `mobile_person_<n>` where `<n>` is a monotonic row counter (gaps appear when rows are removed). The server discovers which indexes were actually posted (`_PERSON_FIELD_RE`, never a client-supplied count), drops entirely blank rows, and re-keys people **by position** into `p1, p2, …` for storage. So a customer deleting a middle row cannot shift anyone's answers, and stored matrix keys are always dense.
|
||
|
||
**Admin-only tasks are enforced server-side.** `task_applies()` gates ref 10 (Search/Export Reports) to `ADMIN_ROLES`; the POST parser only reads cells the person's role offers, so a crafted POST cannot record an admin-only task against an inspector — verified.
|
||
|
||
**Rows 7, 9 and 10 record an expectation; they do not switch anything on** (`schema.ROLE_IMPLIED_TASKS`, rendered as a footnote under Step 2). Both customer roles already carry all three today — comment on issues they follow or filed, log an issue at their own facility, search/export reports within their scope — so a per-person flag would be a **deny**-check, meaning new gates on routes that have none, i.e. a fail-open surface for no gain (the rule 87 failure class, self-inflicted). They stay in the form and the CSV because they are a useful record of what the customer expected, and they drive the Recommendation preset. **Rows 1–6 and 8 are the ones that map to notification events** and can be tuned per account in Customer Management (§5 `UserNotificationMatrix`) — seeding those overrides from a submission's ticks is a deliberate follow-up, not built: it needs a person↔account match by email that nothing in the system does yet, and rule 88 forbids the public form reaching into accounts, so it can only ever be an admin-triggered, confirm-before-save action.
|
||
|
||
### `schema.py` is the source of truth
|
||
|
||
`ROLES`, `ADMIN_ROLES`, `TASKS` (10 rows; ref 10 is `admin_only`), `RECOMMENDATION`, `OFFICE_FIELDS`, `STATUSES`. The public template renders from it *and hands it to the page as JSON* (`ROLES`, `TASKS`, `ADMIN_ROLES`, `recommendation_map()`), the POST parser iterates it, and the admin views re-render stored answers through it — so adding a task row or a role is a one-line edit with no template, JS or parser change.
|
||
|
||
**Legacy submissions.** Files stored in the original fixed-seat format are never rewritten; `schema.people_of()` / `cell()` / `wants_mobile()` normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file.
|
||
|
||
### Emails on submission
|
||
|
||
Two messages, both fired *after* `storage.save()`, both on a background thread via `_dispatch()` (rule 14), both From `branded_sender()` (rules 64/76):
|
||
|
||
| To | Function | Template | Contents |
|
||
|---|---|---|---|
|
||
| The **requester** | `send_confirmation()` | `email_confirmation.html` | Reference, project, the people table. Corrections are directed to `schema.CORRECTIONS_EMAIL`, **not** a reply — the From is an unmonitored no-reply. |
|
||
| **JQC admins** | `send_admin_notification()` | `email_admin_notice.html` | Project, requester, reference, people table, customer notes, and an **"Open in JQC"** deep link to `/enrollment/admin/<id>` built from the submitting host (so a multi-domain deployment links to the host actually in use). |
|
||
|
||
**Recipients** come from `_admin_recipients()`: active `admin` accounts, plus any addresses in the optional **`ENROLLMENT_NOTIFY_EMAILS`** config (comma-separated) for people who should be told but hold no JQC login. Deduplicated case-insensitively. Directors, inspectors and *inactive* admins are excluded — verified.
|
||
|
||
**Neither email can cost a customer their enrollment.** Every failure path is caught and logged: no `MAIL_SERVER`, `mail.send` raising, the template blowing up, or the admin lookup failing because the DB is unreachable — all still return the normal thank-you page with the record safely on disk. Verified for all four. A missing admin list does not suppress the requester's confirmation.
|
||
|
||
### Storage
|
||
|
||
One JSON document per submission in `ENROLLMENT_DIR`, named `<YYYYmmdd-HHMMSS>-<8 hex>.json` — time-ordered so a directory listing sorts chronologically, random suffix so two submissions in the same second cannot collide. The stem is the submission id and the **only** thing the admin URLs accept.
|
||
|
||
- **`_ID_RE` guards every filesystem access.** Ids are validated against `^\d{8}-\d{6}-[0-9a-f]{8}$` before being joined to a path, so a crafted id (`../../etc/passwd`) can never escape the directory — verified.
|
||
- **Writes are atomic** (`tempfile` in the same dir → `os.replace`), so a crash mid-write cannot leave truncated JSON that would break the admin list for every other submission.
|
||
- `load_all()` skips a corrupt file with a log line rather than failing the whole page.
|
||
- **Customer answers are immutable after submission.** `update_office()` merges only the office block + status, so the file stays a faithful record of what was actually requested.
|
||
|
||
### Public page hardening (same posture as rule 74)
|
||
|
||
Login-free, so: CSRF-protected form, `@limiter.limit('5 per hour')` on POST only, honeypot field (`website`, CSS-hidden — a bot that fills it gets a 200 and no file), submit button disabled on first click, `noindex` meta, and a standalone template with no authenticated nav. Validation requires a project name, a requester, at least one person with **both** a name and an email (a half-filled row cannot be set up, so it must not pass as one), and no duplicate email addresses; on failure it re-renders with the customer's input intact — including their ticked boxes, folded per-person into the `seed_people` payload — and returns 400.
|
||
|
||
### Admin
|
||
|
||
`/enrollment/admin` (admin-only, linked from the **Admin** nav dropdown in both layouts). List → detail → office-use fields (Receive Date / Program By / Date email invitation) + status (new / in_progress / completed). `GET /admin/<id>.json` downloads the raw file; `GET /admin/export.csv` emits **one row per person, not per submission** — that is the unit of work when actually creating the accounts. Task cells a person's role cannot have export as `n/a`, distinct from an unticked `''`.
|