Files
LT_Janitorial_Quality_Control/CLAUDE.md
T

1229 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 AE + 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 R1R4 + 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 3032 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)
---
## 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 AE)](#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)
---
## 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, 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 `llama-3.3-70b-versatile`. |
### 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`
**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`** (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_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
```
**Handler (`handler_type`, Phase 35) — who is doing the work:**
| Value | Meaning | Detail fields | `assigned_to` role |
|---|---|---|---|
| `internal` (default) | Our staff | — (the assignee IS the handler) | 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** |
`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 facility or 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 | "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.
### 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
```
**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)
```
### 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 phase3032.
### 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
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36
```
**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.
- **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`.
- **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).
Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_required`; **Start** is the assigned inspector or a manager; inspectors' list/dashboard views are scoped to their own `inspector_id`.
---
## 6. Role & Permission Matrix
| 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 |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own |
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only |
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ |
| 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')
@customer_required # role == 'customer' only
```
---
## 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. **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). |
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary; `POST /<token>/report` occupant issue report (rate-limited `5/hour`, honeypot). Resolves ACTIVE facility 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` | list, invite, set-password, manage, import CSV |
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
| `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 |
| `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, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
| `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `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.
### `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)
**`_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 AE)
### 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) |
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema |
### 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` |
### 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.
### Issue API — `_issue_payload()` fields
```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)
}
```
**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` 0100 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
```
### 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 ← HEAD
```
### 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.
### phase3032_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"
```
**Deploy order for phases 2432:**
```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.
### Inspection List Filters
`inspections.index()` accepts five additional query params: `date_from`, `date_to` (ISO date strings), `score_min`, `score_max` (0100 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 three additional query params: `issue_id` (exact match on `Issue.id`), `date_from`, `date_to` (ISO date strings applied to `Issue.reported_at`). The `date_to` end is expanded to `23:59:59` so the whole day is included.
`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 — `status=open` with severity breakdown badges
2. Issues Opened Today — links to `issues.index` with `date_from=today&date_to=today` (uses the date filter added to `issues.index`)
3. Resolved Today — `status=resolved` + today's date range
4. Pending Verification — `status=pending_verification`
5. Unassigned Open — `status=open` issues with no `assigned_to`
Each card has a subtitle line 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 — Phase R1: Issues Aging (`/reports/issues-aging`)
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `13 days`, `37 days`, `14 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.
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
### 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 |
| 2429 | *(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 |
| 3338 | *(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 AE 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 a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, and honeypot-guarded; public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. 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`. |
---
## 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 13 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