# 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) 24. [Enrollment Form](#24-enrollment-form-enrollment) 25. [Database Health Check](#25-database-health-check-scriptsdb_healthpy) --- ## 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//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 `/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. | | `DB_POOL_RECYCLE` | Optional, default `1800` (seconds). Retires a pooled connection after this long. **Must stay below the server's `wait_timeout`** or MySQL closes the socket first and the next request gets `OperationalError 2006`. `scripts/db_health.py` cross-checks the two. | | `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` | Optional, default `5` / `5`. Per-**worker** pool. Gunicorn runs `cpu*2+1` sync workers and each holds its own pool, so the ceiling is `workers x (size + overflow)` — the library defaults (5+10) put a 9-worker box at 135 against a `max_connections` of 151. A sync worker serves one request at a time and needs one connection in steady state; the overflow is headroom for the background email/notification threads. | | `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//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/`** where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /customers//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/` — 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/` — 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/` and `/f/` 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//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//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//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 ``` ### IssueLink (Phase 54) ``` issue_links: id, issue_id (FK→issues CASCADE, indexed), linked_issue_id (FK→issues CASCADE, indexed), link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related', created_by (FK→users SET NULL), created_at UniqueConstraint(issue_id, linked_issue_id) — uq_issue_link ``` **Connects a duplicate to its original, or two issues about the same thing**, so whoever picks one up can reach the other. **One row is stored per pair and shown on BOTH issues.** The stored direction carries meaning for `duplicate` — `issue_id` is a duplicate *of* `linked_issue_id` — so the same row reads differently at each end: "Duplicate of #B" on one, "Duplicated by #A" on the other. `related` is symmetric and reads "Related to" from either side. `IssueLink.LABELS` is keyed `(link_type, is_source)` and is the only place that wording lives; `label_for(viewing_issue_id)` / `other_issue(viewing_issue_id)` resolve a row against whichever issue is on screen. Storing one row rather than a mirrored pair keeps the direction unambiguous and makes unlinking a single delete. The cost: **uniqueness cannot be expressed by the UniqueConstraint alone.** `(A,B)` and `(B,A)` are distinct rows to MySQL but the same link to a person, so **`IssueLink.exists_between(a, b)` is the only correct duplicate check** — it looks both ways. The constraint catches the exact-duplicate row; `exists_between()` catches the reverse. **A link is PURELY NAVIGATIONAL** (decided Sep 2026). Marking a duplicate does **not** touch either issue's status, `resolved_at`, SLA, assignee or followers, and fires no notification. Closing the duplicate stays a separate, deliberate action. Do not add side effects here without saying so in the UI — the link control reads as navigation, and a status write from it would be invisible. **Two FKs from one table to `issues`, so both relationships pin `foreign_keys`** — rule 86's failure mode, which raises on first ORM *use*, not at import. `Issue.links_from` / `Issue.links_to` are the two storage directions; **`Issue.all_links()` merges them** into the single list a person actually sees. Both relationships cascade `all, delete-orphan` (and both FKs are `ON DELETE CASCADE`), so deleting an issue takes its links from *either* end — a surviving link would render a dead row on the other issue's page. **Scope is the thing to get right here — see rule 99.** ### 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/`) - 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 `