# 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) --- ## 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) --- ## 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 `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`, `auditor` **`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 + dates only — never checklist names, per-inspection 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 ``` **Handler (`handler_type`, Phase 35) — who is doing the work:** | Value | Meaning | Detail fields | `assigned_to` role | |---|---|---|---| | `internal` (default) | Janitorial Staff (our crew) | — (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** | **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. `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//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. ### Notification / NotificationPreference ``` notifications: id, user_id, title, body, link, is_read, created_at, issue_id, inspection_id, event_type VARCHAR(50) NULL, digest_pending notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency ``` ### IssueComment ``` issue_comments: id, issue_id (FK), user_id (FK), body, created_at, status_at_time, is_customer_visible (BOOLEAN, default False) ← Phase 22 ``` **`is_customer_visible`:** Staff comments are hidden from customers by default (`False`). Staff can tick "Share with customer" at post time to set `True`. Customer-authored comments are always stored as `True`. Customers see only `is_customer_visible=True` comments; staff see all. ### FacilityScoreAlert ``` facility_score_alerts: id, facility_id (FK→facilities CASCADE), sent_at DATETIME, current_avg DECIMAL(5,2), prior_avg DECIMAL(5,2), delta DECIMAL(5,2) INDEX ix_fsa_facility_sent (facility_id, sent_at) ``` Records each score-trend alert dispatched for a facility. `send_score_alerts()` queries this table to skip re-alerting a facility within the last 24 hours, preventing notification storms on persistent score drops. ### SupportTicket / SupportTicketReply ``` support_tickets: id, customer_id (FK→users SET NULL), facility_id (FK→facilities SET NULL), subject VARCHAR(200), body TEXT, status VARCHAR(20) DEFAULT 'open', created_at DATETIME status values: open / answered / closed support_ticket_replies: id, ticket_id (FK→support_tickets CASCADE), user_id (FK→users SET NULL), body TEXT, created_at DATETIME ``` ### SupportChatSession / SupportChatMessage (Phase 37) ``` support_chat_sessions: id, customer_id (FK→users CASCADE, indexed), created_at, updated_at (indexed) support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE, indexed), role ('user'|'assistant'), content TEXT, created_at ``` Persists the customer AI support chat. `chat_message()` writes both the user turn and the assistant reply into the session (creating one lazily on the first message; `session.updated_at` bumped each turn). `chat()` reloads the customer's **most recent** session into the chat window for continuity (unless `?new=1`). Read-only history views exist for the customer (`/support/my-conversations`) and staff (`/support/admin/conversations`). `SupportChatSession.preview` = first user message; `.message_count` for list views. See §18 "Support Chat". ### SupportKnowledge (Phase 38) ``` support_knowledge: id, title VARCHAR(200), content TEXT, active BOOL, sort_order INT, created_by (FK→users SET NULL), created_at, updated_at ``` Admin-curated knowledge entries that "train" the AI chatbot **without code changes**. `_system_prompt_with_kb()` in `routes/support.py` appends every **active** entry (ordered by `sort_order`, id) to the base `_SYSTEM_PROMPT` on each chat request, soft-capped at `_KB_MAX_CHARS` (6000). Managed by admin/director at `/support/admin/knowledge` (list/new/edit/delete). The base `_SYSTEM_PROMPT` is a comprehensive, **customer-scoped** description of the app; the KB is the incremental, non-dev-editable layer on top. The chatbot is Groq/Llama (`GROQ_MODEL`, default `llama-3.3-70b-versatile`) — **not** fine-tuned; all "knowledge" is prompt context. **Flow:** - Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email) - Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/`) - 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 phase30–32. ### Broadcast ``` broadcasts: id, title VARCHAR(255), body TEXT, target_roles (JSON list of role strings), sent_by_id (FK→users SET NULL), sent_at DATETIME, recipient_count INT ``` Admin-authored broadcast messages. Sending a broadcast fans out one `Notification` row per targeted user; the iPad picks them up through its existing `GET /api/v1/notifications?since=...` poll — **no dedicated broadcast API endpoint exists**. `recipient_count` snapshots how many notifications were created. Managed at `/admin/broadcast` (see §7 `broadcast` blueprint). ### ContractNotificationRecipient ``` contract_notification_recipients: id, project_id (FK→projects CASCADE, indexed), user_id (FK→users CASCADE, nullable, indexed), -- staff-user recipient email VARCHAR(200) nullable, -- external email recipient event_types TEXT (JSON list of event_type keys), created_at ``` **Per-contract additional notification recipients.** Each row is ONE extra recipient attached to a Contract who is notified — for the `event_types` they subscribe to — whenever those events fire within that contract's facilities, **in addition to** the global `NotificationMatrix` routing. Exactly one of `user_id` / `email` is set (enforced in the route, not the DB): - `user_id` set → existing staff user → **in-app notification + email** - `email` set → free-form external address → **email only** `event_types` is a JSON list of `MATRIX_EVENTS` keys. A recipient fires only when the event is in its list. Managed admin-only on the **Contract detail page** (`/projects/`) 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 **`auditor` reads as a `project_manager` column** below, with these overrides: **Issues (quick-assign)** ✅, **Issue verification** ✅, and it appears in the **Issues (create/assign)** and **Issue verification** rows as ✅. It never gains issue *delete* or any admin/director-only row PM lacks. See the `auditor` note in §5. | Area | admin | director | project_manager | inspector | customer | |---|---|---|---|---|---| | Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped | | Users | ✅ | ✅ | ❌ | ❌ | ❌ | | Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ | | Customers | ✅ | ✅ | ❌ | ❌ | ❌ | | Facilities | ✅ | ✅ | ✅ | read | scoped | | Facility QR (view/print) | ✅ | ✅ | ✅ | ✅ | scoped | | Facility QR (regenerate) | ✅ | ✅ | ❌ | ❌ | scoped | | Contracts | ✅ | ✅ | ✅ | read | scoped | | Templates | ✅ | ✅ | ❌ | ❌ | ❌ | | Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read | | 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', 'auditor') @issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete) @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: `//qr` printable page, `//qr.png` image, `POST //qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas//qr`, `/areas//qr.png`, `POST /areas//qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `