# 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 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); MT-9 iOS pending**) --- ## 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. [Multi-Tenant Architecture (MT-0 → MT-8)](#22-multi-tenant-architecture-mt-0--mt-8) 23. [Billing System (MT-8)](#23-billing-system-mt-8) 24. [Backup CLI](#24-backup-cli-controlbackuppy) 25. [Health Dashboard](#25-health-dashboard-health-on-panel) 26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants) --- ## 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) - **Billing** — Stripe-backed subscription system with plan picker, Checkout, Customer Portal, dunning emails (HTML + plain text), invoice history, and trial-period enforcement - **Public landing page** — `jqc.app` apex serves a marketing/landing page (`landing.index`, canonical path `/welcome`) introducing the product and plans, funneling to signup - **Self-service signup** — public `/signup` page provisions a new tenant immediately: Free plan = free-forever (`active`), paid plans = 14-day trial (no Stripe required at signup) 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 (RoutingSession), mail, login_manager, init_tenancy │ ├── tenancy/ # MT-1 — tenant resolution + DB routing (inert unless MULTI_TENANT_ENABLED=true) │ │ ├── __init__.py # public exports: RoutingSession, init_tenancy, TenantContext, feature_required, quota_soft_check │ │ ├── context.py # TenantContext frozen dataclass (g.tenant) — MT-5: +9 plan fields │ │ ├── engine_cache.py # per-tenant SQLAlchemy engine cache + invalidate() │ │ ├── gates.py # MT-5: @feature_required (hard 403) + @quota_soft_check (soft warn) │ │ ├── middleware.py # init_tenancy() — before_request Host→tenant resolver + MT-4 impersonation override │ │ ├── quota.py # MT-5: live quota counters (inspections/issues/users/facilities) │ │ ├── resolver.py # resolve_tenant(host) → TenantContext | None — MT-5: loads plan fields │ │ └── routing.py # RoutingSession — routes db.session to g.tenant_engine │ ├── 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) │ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB) │ │ └── ... │ ├── routes/ │ │ ├── support.py # /support/* — AI chat, ticket submit/list/detail (Phase 23) │ │ ├── tenant_settings.py # MT-7: /settings/* — branding, plan view (+ invoice history), domain mgmt (@admin_required) │ │ ├── signup.py # public /signup — self-service tenant signup (no auth, no tenant context) │ │ └── ... │ ├── templates/ │ │ ├── _quota_warning.html # MT-5: reusable quota exceeded banner partial │ │ ├── tenant_settings/ # MT-7: branding.html, plan.html, domains.html │ │ ├── billing/ │ │ │ ├── _billing_banner.html # past_due + trial_ending warning banners │ │ │ ├── plan_picker.html # plan selection page (Starter/Pro/Enterprise cards) │ │ │ └── email/ # HTML billing lifecycle emails │ │ │ ├── base.html # shared branded email layout │ │ │ ├── payment_failed.html │ │ │ ├── trial_ending.html │ │ │ └── subscription_cancelled.html │ │ ├── signup/ │ │ │ ├── index.html # self-service signup form (standalone, no base.html) │ │ │ └── success.html # post-signup confirmation │ │ └── ... │ ├── 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/ │ ├── 0003_add_user_active.py ← squashed baseline (chain root, MT-2) │ └── phase32_device_token_columns ← HEAD ├── migrations_tenant/ # MT-2 — standalone Alembic env for per-tenant upgrades │ ├── env.py # URL-driven, no Flask; reuses migrations/versions │ └── script.py.mako ├── control/ # MT-0 — control plane (tenant registry, plans, provisioning) │ ├── __init__.py │ ├── base.py # ControlBase, engine/session (CONTROL_DATABASE_URL) │ ├── cli.py # seed / create-superadmin / list-plans │ ├── crypto.py # Fernet encrypt/decrypt (CONTROL_FERNET_KEY) │ ├── models.py # Plan, PlanFeature, Tenant, TenantDomain, │ │ # Superadmin, ProvisioningJob, TenantAudit │ ├── provision.py # MT-3 — create_tenant / register_tenant_zero / delete_tenant │ ├── backup.py # per-tenant mysqldump backup CLI (python -m control.backup) │ ├── seed.py # idempotent plan seeder │ ├── tenant_migrate.py # MT-2 — upgrade_tenant / bootstrap_tenant / chain_head CLI │ ├── time_utils.py # now_eastern() mirror (no app import) │ ├── panel/ # MT-4 — superadmin control panel (standalone Flask app) │ │ ├── __init__.py # create_panel_app() factory │ │ ├── auth.py # /login, /logout (session-based, no Flask-Login) │ │ ├── decorators.py # @superadmin_required │ │ ├── impersonate.py # HMAC-SHA256 signed token generator/validator │ │ ├── tenants.py # CRUD + plan/suspend/resume/domain/migrate/provision/billing routes │ │ ├── health.py # GET /health/ — monitoring dashboard (tenant status, schema, trials) │ │ ├── wsgi_panel.py # WSGI entry point — Gunicorn on port 8001 │ │ └── templates/panel/ # base.html, login.html, tenants_list.html, tenant_detail.html, provision.html, health.html │ └── migrations/ # control Alembic chain: control0001_init → control0002_billing → control0003_trial_reminder_sent → control0004_dunning_tracking ← HEAD │ └── versions/control0001_init.py … control0004_dunning_tracking ← HEAD ├── deploy/ │ └── fail2ban/ # Fail2ban filter + jail configs for login brute-force protection │ ├── filter.d/jqc-login.conf # Matches WARNING LOGIN_FAILED from app log │ └── jail.d/jqc.conf # maxretry=5 / findtime=300s / bantime=3600s └── ... ``` --- ## 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`. | | `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. | | `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. | | `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. | | `PROVISION_DB_URL` | MySQL account that can `CREATE DATABASE` / `CREATE USER` / `GRANT`. e.g. `mysql+pymysql://jqc_provisioner:pw@127.0.0.1/`. | | `TENANT_BASE_DOMAIN` | Apex domain for subdomains, e.g. `jqc.app`. Used by provisioner to build `.jqc.app`. | | `MULTI_TENANT_EXEMPT_PATHS` | Comma-separated path prefixes that bypass the tenant gate (e.g. `/health`). `/static/`, `/signup`, `/welcome`, and the cross-tenant cron paths are always exempt (see `_is_exempt()` in `app/tenancy/middleware.py`). | | `TENANT_ENGINE_POOL_SIZE` | Per-tenant engine pool size (default 5). | | `TENANT_ENGINE_MAX_OVERFLOW` | Per-tenant pool max overflow (default 5). | | `TENANT_ENGINE_POOL_RECYCLE` | Pool recycle in seconds (default 1800). | | `PANEL_SECRET_KEY` | Flask secret for the superadmin panel app (separate from `SECRET_KEY`). Generate: `python -c "import secrets; print(secrets.token_hex(32))"` | | `PANEL_IMPERSONATE_KEY` | HMAC key for impersonation tokens. Must be identical between panel and main app env. Generate same way. | | `BILLING_ENABLED` | `false` by default. Set `true` to activate Stripe billing UI, gate, and webhooks. | | `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_…` or `sk_test_…`). Required when `BILLING_ENABLED=true`. | | `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_…`). Used in Checkout redirect. | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_…`). Validates webhook payloads. | | `STRIPE_PRICE_STARTER` | Stripe Price ID for the Starter plan (`price_…`). Set via `python -m control.cli seed`. | | `STRIPE_PRICE_PRO` | Stripe Price ID for the Pro plan. | | `STRIPE_PRICE_ENTERPRISE` | Stripe Price ID for the Enterprise plan. | ### 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, mfa_enabled BOOL default False, mfa_secret VARCHAR(64) NULL, ← phase35 mfa_recovery_codes JSON NULL ← phase35 ``` **MFA (phase35):** Opt-in TOTP two-factor. `mfa_enabled` gates a second-factor step at login (`/auth/mfa`). `mfa_secret` is the base32 TOTP shared secret. `mfa_recovery_codes` is a JSON list of werkzeug-hashed one-time backup codes (never plaintext). Enrollment UI at `/auth/mfa/setup` is `@supervisor_required` (admin/director); the login challenge fires for **any** account with `mfa_enabled=1`. The superadmin panel has the mirrored flow on the `Superadmin` control-plane model. **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), qr_token VARCHAR(64) UNIQUE NULL ← phase38 areas: id, facility_id (FK), name, area_type ``` **`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other` **`qr_token` (phase38):** Unguessable token (`secrets.token_urlsafe(32)`) behind the public facility QR scan page `GET /f/` (blueprint `facility_qr`, rule 91). NULL until first requested — `Facility.ensure_qr_token()` generates it lazily when staff open the QR card (`/facilities//qr`) or bulk print sheet (`/facilities/qr-sheet`). Regenerating (`POST /facilities//qr/regenerate`, `@supervisor_required`, audited) invalidates all previously printed posters. ### 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 project_notification_recipients: id, project_id (FK→projects CASCADE), ← phase37 user_id (FK→users CASCADE, nullable), email VARCHAR(255) nullable, events TEXT (JSON list of MATRIX_EVENTS keys), created_at ``` **`project_notification_recipients` (phase37):** Per-contract additional notification recipients, layered on top of the global notification matrix. Exactly one of `user_id` (staff → in-app + email via `notify()`) / `email` (external → email only) is set — enforced in the route layer, not by a DB constraint. `notify_by_matrix()` calls `_notify_project_recipients()` AFTER the matrix roles and global custom emails: it resolves the contract via `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`, then notifies every recipient of that contract subscribed to the event. Deduplicated against matrix-role notifications (user IDs) and global custom emails (lowercased). Managed at `/projects//recipients` (`@supervisor_required`); re-adding an existing recipient replaces its event list (upsert). ### 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') nullable, ← phase39 facility_handler_name VARCHAR(100) nullable, ← phase39 facility_handler_contact VARCHAR(200) nullable, ← phase39 facility_handler_notes TEXT nullable ← phase39 ``` **`handler_type` (phase39):** Who is responsible for resolving the issue. `NULL` and `'internal'` both mean janitorial staff (the default). `'facility'` activates the `facility_handler_*` sub-fields (contact at the building). `'vendor'` indicates an external contractor and cross-references the existing `vendor_*` fields and work orders. Editable by admin/director/project_manager on the issue update form. Dashboard shows a three-card handler breakdown for staff roles. Issues list accepts `?handler_type=` filter. `Issue.HANDLER_LABELS` maps enum values to display names. **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 | "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/`) - 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) ``` ### InspectionSchedule (phase34) ``` inspection_schedules: id, name VARCHAR(255), template_id (FK→inspection_templates CASCADE), facility_id (FK→facilities CASCADE), area_id (FK→areas SET NULL, nullable), inspector_id (FK→users CASCADE), frequency ENUM(daily/weekly/monthly/quarterly), active BOOL, created_by (FK→users SET NULL), created_at DATETIME, last_run_at DATETIME NULL, next_run_at DATETIME NULL INDEX ix_ischd_active_next (active, next_run_at) ``` Recurring inspection generator. `POST /inspection-schedules/run` (token-protected cron) walks active schedules where `next_run_at <= now`, creates one `in_progress` Inspection per due schedule (assigned to `inspector_id`, dated now), notifies the inspector (`event_type='inspection_scheduled'`), then advances `next_run_at`. Managed at `/inspection-schedules` by admin/director/project_manager. Purely additive — a schedule is an automated `inspections.start()`. ### IssueWorkOrder (phase36) ``` issue_work_orders: id, issue_id (FK→issues CASCADE), vendor_name VARCHAR(150), vendor_email VARCHAR(255), token VARCHAR(64) UNIQUE, status ENUM(sent/acknowledged/completed), message TEXT, vendor_note TEXT, sent_at, acknowledged_at, completed_at DATETIME, created_by (FK→users SET NULL), created_at DATETIME INDEX ix_wo_issue (issue_id) ``` Vendor work-order dispatch. Staff (admin/director/PM) send an issue to an external contractor via `POST /issues//work-order`, which creates a row with an unguessable `token` and emails the contractor a link. The contractor uses the public, login-less pages (`GET/POST /work-orders/`) to **Acknowledge** and **Complete** the work — the token is the sole authorization. On completion the parent issue moves to `pending_verification` so staff sign off. Each vendor action notifies the issue's reporter/assignee/followers (`event_type='work_order_update'`). Builds on the free-text `vendor_*` Issue fields (phase26). ### 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, registered_at, ios_version (phase31/32), last_seen_at (phase31/32) UniqueConstraint(user_id, device_id) ``` `api_device_tokens` (model `DeviceToken`) is the canonical device registry read by the admin Devices page (`/admin/devices`). ### Broadcast (phase29) ``` broadcasts: id, title VARCHAR(255), body TEXT, target_roles JSON (list[str]), sent_by_id (FK→users SET NULL), sent_at DATETIME, recipient_count INT ``` Admin composes a message at `/admin/broadcast`; `broadcast.send` writes one `Notification` row per active user in the targeted roles (`event_type='admin_broadcast'`) plus one `broadcasts` audit row. iOS picks the notifications up on its existing `GET /api/v1/notifications?since=` poll — no APNs/FCM required. ### ~~DeviceRegistration~~ (phase30 — REMOVED, see rule 84) The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **deleted** (rule 84 resolution). Device tracking is consolidated on `DeviceToken` / `api_device_tokens` via the canonical `POST /api/v1/devices/register` handler in `app/api/auth.py`. The `device_registrations` **table is dropped** by phase31/32 on existing tenants; the baseline (`0003_add_user_active`) still creates it, so freshly-bootstrapped tenants carry a harmless empty orphan table that nothing reads or writes. --- ## 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 | | 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`, `/mfa` (login 2FA challenge), `/mfa/setup` + `/mfa/disable` (phase35, `@supervisor_required` enroll/disable) | | `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) | | `facilities` | `/facilities` | CRUD + area management + QR codes (phase38): `GET //qr` printable card + `GET /qr-sheet` bulk print (`@project_manager_required`), `POST //qr/regenerate` (`@supervisor_required`) | | `facility_qr` | `/f` | phase38 — **public, login-less** facility QR scan page: `GET /` shows counts-and-scores-only snapshot (90-day stats, 30-day score trend, open-issue severity/SLA counts, recent inspection scores). Token is the authorization (rule 91). Hybrid: logged-in scanners with facility scope get a link to the full internal view | | `projects` | `/projects` | CRUD + customer assignment management + per-contract notification recipients (`GET //recipients`, `POST //recipients/add`, `POST /recipients//remove` — `@supervisor_required`, phase37) | | `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), trial-reminders (cron), dunning-reminders (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) | | `inspection_schedules` | `/inspection-schedules` | phase34 — recurring inspection CRUD (`@project_manager_required`) + `POST /run-now` (manual) + `POST /run` (token-protected cron materialiser) | | `work_orders` | `/work-orders` | phase36 — **public, login-less** vendor pages: `GET /` (contractor view) + `POST /` (acknowledge/complete). Token is the authorization. Staff dispatch is `POST /issues//work-order` on the `issues` blueprint (`@project_manager_required`). | | `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` — admin-only push to iOS via Notification rows (phase29) | | `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) | | `api` | `/api/v1` | parent blueprint | | `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` (device tracking + APNs token → `api_device_tokens`) | | `api_facilities` | `/api/v1` | `/facilities`, `/facilities//areas` | | `api_templates` | `/api/v1` | `/templates`, `/templates/` | | `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/` | | `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/`, `PATCH /issues//status`, `PATCH /issues//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//comments`, `POST /issues//comments` (Phase D) | | `api_discovery` | `/api/v1` | **NOT YET IMPLEMENTED (MT-9 pending).** Planned public discovery endpoints (`GET /discover?subdomain=` or `?email=`, `GET /tenant`), no auth, to be exempt from tenant middleware. No blueprint or route exists in the code yet — do not reference these as live. | | `tenant_settings` | `/settings` | MT-7: `GET/POST /branding`, `GET /plan`, `GET /domains`, `POST /domains/request`, `POST /domains//delete` | | `billing` | `/billing` | `GET /subscribe`, `POST /subscribe`, `GET /portal`, `POST /webhook`, `GET /suspended` | | `signup` | `/signup` | `GET /`, `POST /` — public self-service signup (exempt from tenant middleware). Free plan → provisioned `active` (free forever); paid plans → 14-day trial | | `landing` | `/` (apex only) | `GET /welcome` — public marketing/landing page (tenant-exempt). In MT mode the middleware serves this for the apex host's `/` and funnels to `/signup` | --- ## 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. ### `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 A–E) ### CSRF Exemption Pattern — Critical **`csrf.exempt(api_bp)` does NOT cascade to sub-blueprints.** Each child blueprint must be exempted individually in `app/__init__.py`. The new `api_issues` blueprint (including its `PATCH /issues//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//areas` | jwt_required | Areas for a facility | | `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) | | `GET /api/v1/templates/` | 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/` | 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/` | jwt_required | Single issue detail | | `PATCH /api/v1/issues//status` | jwt_required | Update issue status | | `GET /api/v1/notifications` | jwt_required | Unread notifications; accepts `?since=` | | `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//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//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//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/` and `PATCH /issues//status` and `PATCH /issues//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//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()` filter on the iOS side AND the upsert map (`facilityMap`) on the server side. ### Idempotency Pattern All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt, check for existing record and return `{ 'duplicate': True }` without inserting. Web-created records have `mobile_local_id = NULL`. ### Score Calculation (Server-Side) `app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly. Rating value `0` = unanswered → excluded. Returns `float` 0–100 or `None` if no scoreable fields. --- ## 10. iPad Native App See the iOS app's own `CLAUDE.md` for full details. Key integration points: - App connects to `jqc.ltservicesinc.com` (primary) or `jqc1.ltservicesinc.com` (secondary) — **server is user-selectable at login and in Settings**. - Server selection is persisted to `UserDefaults` via `ServerConfig`. Switching server in Settings triggers a logout confirmation alert and clears all server-pulled SwiftData records (`serverId != nil`) before logout. - All photo evidence from the iPad routes through `mobile_photo_paths` on the server — never through `result_photos`. --- ## 11. Notification System ### Event Constants (`app/models/notification.py`) ``` EVENT_ISSUE_ASSIGNED = 'issue_assigned' EVENT_ISSUE_STATUS = 'issue_status' EVENT_ISSUE_COMMENT = 'issue_comment' EVENT_ISSUE_FOLLOW = 'issue_follow_update' EVENT_INSPECTION_DONE = 'inspection_completed' EVENT_SLA_ALERT = 'sla_alert' EVENT_ISSUE_FLAGGED = 'issue_flagged' EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' EVENT_SCORE_ALERT = 'score_alert' ← Phase 27 EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled' ← phase34 EVENT_WORK_ORDER = 'work_order_update' ← phase36 ``` ### 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 /notifications/trial-reminders` | Trial-ending warning emails (≤3 days left) | `0 9 * * *` | | `POST /notifications/dunning-reminders` | Payment-failure escalation emails (day 3/7/14) | `0 10 * * *` | | `POST /inspection-schedules/run` | Materialise due recurring inspections (phase34) | `0 6 * * *` | --- ## 12. SLA Engine | Severity | Window | At-Risk | |---|---|---| | critical | 4h | 3h | | high | 24h | 18h | | medium | 72h | 54h | | low | 120h | 90h | At-risk is computed as `AT_RISK_THRESHOLD` (0.75) × the window in `app/utils/sla.py` — it is not a stored constant. Source of truth is `SLA_HOURS` in [app/utils/sla.py](app/utils/sla.py) (`critical=4, high=24, medium=72, low=120`). `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=` --- ## 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 **Current HEAD:** `phase38_facility_qr` (36 migrations total). **Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`. ``` 0003_add_user_active (baseline, MT-2) → 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_tenant_settings → phase34_inspection_schedules → phase35_user_mfa → phase36_issue_work_orders → phase37_contract_recipients → phase38_facility_qr → phase39_issue_handler_type ← HEAD ``` ### phase39_issue_handler_type Adds four nullable columns to `issues`: `handler_type ENUM('internal','facility','vendor')`, `facility_handler_name VARCHAR(100)`, `facility_handler_contact VARCHAR(200)`, `facility_handler_notes TEXT`. Also enables the `POST /f//report` occupant issue reporting endpoint on the `facility_qr` blueprint (no schema change needed — reuses the `issues` table). Guarded with `INFORMATION_SCHEMA` existence checks — safe to re-run. **Deploy order:** ```bash flask db upgrade sudo systemctl restart gunicorn ``` ### phase38_facility_qr Adds `facilities.qr_token VARCHAR(64) NULL` + unique index `uq_facilities_qr_token`. Backs the public facility QR scan page (`GET /f/` — see §5 `qr_token` + the `facility_qr` blueprint + rule 91). NULL for existing rows; tokens generate lazily via `Facility.ensure_qr_token()` when staff first print a QR card/sheet. Guarded with `INFORMATION_SCHEMA` column + index existence checks — safe to re-run. **Deploy order:** ```bash flask db upgrade sudo systemctl restart gunicorn ``` ### phase37_contract_recipients Creates the `project_notification_recipients` table backing per-contract additional notification recipients (see §5 model + the `/projects//recipients` routes). Each row subscribes one recipient — a staff User (in-app + email) or an external email address (email only) — to a chosen set of notification-matrix event types, scoped to events occurring in that contract's facilities. Dispatched by `notify_by_matrix()` → `_notify_project_recipients()`. Guarded by an `INFORMATION_SCHEMA` table-existence check — safe to re-run. **Deploy order:** ```bash flask db upgrade sudo systemctl restart gunicorn ``` ### phase36_issue_work_orders Creates the `issue_work_orders` table backing the vendor work-order workflow (see §5 model + the `work_orders` blueprint). A work order dispatches an existing Issue to an external contractor by email; the contractor opens a tokenized public link (no account) to acknowledge and complete it. Guarded by an `INFORMATION_SCHEMA` table-existence check — safe to re-run. **Deploy order:** ```bash flask db upgrade sudo systemctl restart gunicorn ``` ### phase35_user_mfa Adds opt-in TOTP two-factor columns to `users`: `mfa_enabled TINYINT(1) NOT NULL DEFAULT 0`, `mfa_secret VARCHAR(64) NULL`, `mfa_recovery_codes JSON NULL`. All nullable/defaulted — existing accounts are unaffected until a user enrolls. Enforced at login for any account with `mfa_enabled=1` (enrollment UI gated to admin/director). Recovery codes are stored only as werkzeug hashes. Guarded with `INFORMATION_SCHEMA` column checks — safe to re-run. The control-plane companion migration `control0005_superadmin_mfa` adds the same three columns to `superadmins`. **Deploy order:** ```bash pip install -r requirements.txt # adds pyotp + qrcode flask db upgrade # tenant schema: phase35_user_mfa # control plane (superadmin panel 2FA): alembic -c control/migrations/alembic.ini upgrade head # control0005_superadmin_mfa sudo systemctl restart gunicorn jqc-panel ``` ### phase34_inspection_schedules Creates the `inspection_schedules` table backing recurring/automated inspections (see §5 model + the `inspection_schedules` blueprint). A schedule pairs a template + facility (+ optional area) + inspector + cadence; the cron endpoint `POST /inspection-schedules/run` materialises a real `in_progress` Inspection per due schedule and notifies the inspector (`event_type='inspection_scheduled'`). Uses an `INFORMATION_SCHEMA` table-existence check — safe to re-run. **Deploy order:** ```bash flask db upgrade sudo systemctl restart gunicorn # Add to cron (materialise due schedules daily at 06:00): # 0 6 * * * curl -s -X POST https://yourdomain.com/inspection-schedules/run -d "token=YOUR_DIGEST_SECRET" ``` ### phase28_fix_inspection_notify Data-only. Resets `notification_matrix` rows for `('inspection_completed', role)` where role ∈ `('admin','director','customer')` back to `enabled=1` (they had been inadvertently disabled). No schema change; `UPDATE` on missing rows is a no-op. No `downgrade`. ### phase29_broadcasts Creates the `broadcasts` table (admin-composed push messages to iOS). Uses `CREATE TABLE IF NOT EXISTS` — safe to re-run. See §5 "Broadcast" model and the `broadcast` blueprint. ### phase30/31/32 — device registry (⚠ inconsistent, see rule 84) - **phase30** created a `device_registrations` table. - **phase31** reversed course: added `ios_version` + `last_seen_at` columns to the existing `api_device_tokens` table (phase7) and **dropped** `device_registrations`. Its `ADD COLUMN IF NOT EXISTS` never actually executed on the LT box (MySQL recorded the revision without applying the DDL). - **phase32** re-applies the `api_device_tokens` column adds using proper `INFORMATION_SCHEMA` existence checks, and again drops `device_registrations` if present. **Net end-state:** device tracking lives on `api_device_tokens` (model `DeviceToken`), which the admin Devices page reads and the single `POST /api/v1/devices/register` handler (`app/api/auth.py`) writes. The `device_registrations` table / `DeviceRegistration` model was removed in the rule 84 resolution; the baseline still creates the (now-unused) table, so fresh tenants carry a harmless empty orphan. See rule 84. ### Fresh DB provisioning (multi-tenant) **Never use `flask db upgrade` on an empty database.** Fourteen of the phase migrations are not idempotent (no `INFORMATION_SCHEMA` guards) and will fail on a fresh DB that already has the baseline schema. Use the provisioner instead: ```bash python -m control.tenant_migrate bootstrap --tenant # Runs: upgrade to 0003_add_user_active (builds full schema) → stamp head # Phase migrations are SKIPPED — baseline covers the full schema. ``` Ongoing incremental upgrades (phase33+, which MUST be guarded) use: ```bash python -m control.tenant_migrate upgrade --tenant all ``` ### Standard single-tenant migration deploy ```bash flask db upgrade # existing LT box — safe, already at head sudo systemctl restart jqc ``` ### 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. **Deploy order for phases 24–27:** ```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 - **`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('.')` in each nav `` tag. ### 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 `
` 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 `