1868 lines
130 KiB
Markdown
1868 lines
130 KiB
Markdown
# Claude.md — JQC Developer Reference
|
||
|
||
> **Audience:** AI assistants and developers working on this codebase.
|
||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||
> **Last reviewed:** July 2026 (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); support chat persistence + knowledge base (phase40); Reports R1+R2 contract cascade filter; billing emails branded From address; 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/<id>/comments (Phase D)
|
||
│ │ ├── decorators.py # @jwt_required
|
||
│ │ ├── errors.py # JSON error helpers + error handler registration
|
||
│ │ └── jwt_utils.py # generate_access_token()
|
||
│ ├── models/
|
||
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
||
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
||
│ │ ├── support.py # SupportChatSession, SupportChatMessage, SupportKnowledge (phase40) + 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 `<slug>.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/<token>` (blueprint `facility_qr`, rule 91). NULL until first requested — `Facility.ensure_qr_token()` generates it lazily when staff open the QR card (`/facilities/<id>/qr`) or bulk print sheet (`/facilities/qr-sheet`). Regenerating (`POST /facilities/<id>/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/<id>/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/<id>/photos` — extra evidence photos | "Photo Evidence" (additional) |
|
||
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
|
||
|
||
**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details".
|
||
|
||
**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue. Nullable for backward compatibility. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet.
|
||
|
||
### Notification / NotificationPreference
|
||
|
||
```
|
||
notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
|
||
inspection_id, event_type VARCHAR(50) NULL, digest_pending
|
||
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
||
```
|
||
|
||
### IssueComment
|
||
|
||
```
|
||
issue_comments: id, issue_id (FK), user_id (FK), body, created_at,
|
||
status_at_time, is_customer_visible (BOOLEAN, default False) ← Phase 22
|
||
```
|
||
|
||
**`is_customer_visible`:** Staff comments are hidden from customers by default (`False`). Staff can tick "Share with customer" at post time to set `True`. Customer-authored comments are always stored as `True`. Customers see only `is_customer_visible=True` comments; staff see all.
|
||
|
||
### FacilityScoreAlert
|
||
|
||
```
|
||
facility_score_alerts: id, facility_id (FK→facilities CASCADE), sent_at DATETIME,
|
||
current_avg DECIMAL(5,2), prior_avg DECIMAL(5,2), delta DECIMAL(5,2)
|
||
INDEX ix_fsa_facility_sent (facility_id, sent_at)
|
||
```
|
||
|
||
Records each score-trend alert dispatched for a facility. `send_score_alerts()` queries this table to skip re-alerting a facility within the last 24 hours, preventing notification storms on persistent score drops.
|
||
|
||
### SupportChatSession / SupportChatMessage (phase40)
|
||
|
||
```
|
||
support_chat_sessions: id, customer_id (FK→users SET NULL), title VARCHAR(200) nullable,
|
||
created_at DATETIME, last_msg_at DATETIME
|
||
|
||
support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE),
|
||
role VARCHAR(20) ('user'/'assistant'), content TEXT, created_at DATETIME
|
||
```
|
||
|
||
AI chat conversations are now persisted to the DB. `GET /support/chat?session_id=N` loads a prior session's history. The `POST /support/chat/message` endpoint creates a new session (via `flush()`) on first message and persists both turns after the Groq call succeeds; it rolls back if Groq fails (no empty sessions). The session's `title` is auto-set from the first user message (truncated to 100 chars). History is loaded from DB for Groq context (last 40 messages) — the client no longer sends the history array.
|
||
|
||
### SupportKnowledge (phase40)
|
||
|
||
```
|
||
support_knowledge: id, title VARCHAR(200), body TEXT, active BOOL DEFAULT TRUE,
|
||
created_by (FK→users SET NULL), created_at DATETIME, updated_at DATETIME
|
||
```
|
||
|
||
Admin-curated knowledge base entries. Active entries are appended to the Groq system prompt via `_system_prompt_with_kb()`, capped at `_KB_MAX_CHARS = 6000`. Managed at `/support/admin/knowledge` (`@supervisor_required`): add, edit, toggle active/inactive, delete. Deactivated entries are preserved but skipped from the system prompt.
|
||
|
||
### SupportTicket / SupportTicketReply
|
||
|
||
```
|
||
support_tickets: id, customer_id (FK→users SET NULL), facility_id (FK→facilities SET NULL),
|
||
subject VARCHAR(200), body TEXT, status VARCHAR(20) DEFAULT 'open',
|
||
created_at DATETIME
|
||
status values: open / answered / closed
|
||
|
||
support_ticket_replies: id, ticket_id (FK→support_tickets CASCADE), user_id (FK→users SET NULL),
|
||
body TEXT, created_at DATETIME
|
||
```
|
||
|
||
**Flow:**
|
||
- Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email)
|
||
- Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/<id>`)
|
||
- Customer adds follow-up → status reverts to `open` → admins notified again
|
||
- Admin can manually set: `open` / `answered` / `closed`
|
||
- Closed tickets cannot receive new replies from customers
|
||
|
||
### NotificationMatrix
|
||
|
||
```
|
||
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||
UniqueConstraint(event_type, role_key)
|
||
```
|
||
|
||
### 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/<id>/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/<token>`) 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 /<id>/qr` printable card + `GET /qr-sheet` bulk print (`@project_manager_required`), `POST /<id>/qr/regenerate` (`@supervisor_required`) |
|
||
| `facility_qr` | `/f` | phase38 — **public, login-less** facility QR scan page: `GET /<token>` 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 /<id>/recipients`, `POST /<id>/recipients/add`, `POST /recipients/<rid>/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 /<token>` (contractor view) + `POST /<token>` (acknowledge/complete). Token is the authorization. Staff dispatch is `POST /issues/<id>/work-order` on the `issues` blueprint (`@project_manager_required`). |
|
||
| `support` | `/support` | `GET /chat` (accepts `?session_id=`), `POST /chat/message` (AJAX→Groq, persists turns, returns `session_id`), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /my-conversations`, `GET /my-conversations/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>`, `GET /admin/conversations`, `GET /admin/conversations/<id>`, `GET /admin/knowledge`, `POST /admin/knowledge/add`, `GET/POST /admin/knowledge/<id>/edit`, `POST /admin/knowledge/<id>/toggle`, `POST /admin/knowledge/<id>/delete` (phase40) |
|
||
| `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/<id>/areas` |
|
||
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
|
||
| `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/<id>` |
|
||
| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos` ← Phase 19 |
|
||
| `api_photos` | `/api/v1` | `POST /photos/upload` |
|
||
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
|
||
| `api_stats` | `/api/v1` | `GET /stats/dashboard` — inspector-scoped KPIs with severity breakdown (Phase B) |
|
||
| `api_comments` | `/api/v1` | `GET /issues/<id>/comments`, `POST /issues/<id>/comments` (Phase D) |
|
||
| `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/<id>/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/<id>/photos` route) inherits the exemption already applied to `_api_issues_bp`. **Every new blueprint must add its own `csrf.exempt()` line before `register_api(app)`.**
|
||
|
||
### Auth Flow
|
||
1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex)
|
||
2. Bearer token on every request
|
||
3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued)
|
||
4. `POST /api/v1/auth/logout` → revokes refresh token
|
||
|
||
### Phase A Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user |
|
||
| `GET /api/v1/facilities/<id>/areas` | jwt_required | Areas for a facility |
|
||
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) |
|
||
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema |
|
||
|
||
### Phase B Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
|
||
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) |
|
||
| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id`; accepts `result_photos` list stored in `mobile_photo_paths` |
|
||
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` |
|
||
|
||
### Phase C Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/inspections` | jwt_required | Inspector's own inspection history (paginated) |
|
||
| `GET /api/v1/issues` | jwt_required | Issues assigned to OR reported by current user (inspectors); all non-resolved (admin/director/PM) |
|
||
| `GET /api/v1/issues/<id>` | jwt_required | Single issue detail |
|
||
| `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status |
|
||
| `GET /api/v1/notifications` | jwt_required | Unread notifications; accepts `?since=<ISO 8601>` |
|
||
| `PATCH /api/v1/notifications/mark-read` | jwt_required | Mark list of notification IDs as read |
|
||
|
||
### Phase 19 Endpoint
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `PATCH /api/v1/issues/<id>/photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. |
|
||
|
||
### Phase B (Stats) Endpoint
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/stats/dashboard` | jwt_required | Inspector-scoped KPIs: `today_inspections`, `completed_today`, `open_issues`, `avg_score_30d`, `pending_followups`, `sla_breached`, `sla_at_risk`, `severity_breakdown` (dict: critical/high/medium/low). Inspectors scoped to contracted facilities. Admins/directors/PMs get org-wide numbers. Customers get 403. |
|
||
|
||
### Phase D (Comments) Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/issues/<id>/comments` | jwt_required | All comments oldest-first. Returns: `id`, `issue_id`, `author_name`, `author_role`, `status_at_time`, `body`, `created_at`. Inspectors limited to contracted facilities. |
|
||
| `POST /api/v1/issues/<id>/comments` | jwt_required | Add a comment. Body: `{ "body": "..." }`. Fires `notify_by_matrix('issue_comment')`. Calls `log_action()` after commit. |
|
||
|
||
### Phase E Additions to Existing Endpoints
|
||
|
||
`_issue_payload()` in `issues.py` now returns `area_name` and `assigned_to_name` (both nullable). These populate `LocalIssue.areaNameCache` and `LocalIssue.assignedToName` on the iPad after every `pullAssignedIssues()`. `refreshStatusFromServer()` also refreshes them on demand.
|
||
|
||
`stats.py` now returns `severity_breakdown` dict alongside the existing KPIs. Derived from the already-loaded `open_issues_all` list — zero extra DB queries.
|
||
|
||
### Issue API — `_issue_payload()` fields
|
||
|
||
```python
|
||
{
|
||
'id', 'status', 'severity', 'description', 'assigned_to',
|
||
'facility_id', 'facility_name', 'reported_at', 'resolved_at',
|
||
'mobile_local_id',
|
||
'photo_path', # primary evidence photo (first iPad photo or web upload)
|
||
'mobile_photo_paths', # extra evidence photos from iPad (list)
|
||
'result_photos', # resolution photos added via web form (list)
|
||
# Phase A additions:
|
||
'result_notes', # resolution notes entered by web staff
|
||
'verified_at', # ISO 8601 datetime when fix was verified (nullable)
|
||
'verification_note', # note from the verifier (nullable)
|
||
'reported_by_name', # display_name of User who filed the issue (nullable)
|
||
# Phase E additions:
|
||
'area_name', # name of the Area the issue was flagged in (nullable)
|
||
'assigned_to_name', # display_name of currently assigned User (nullable)
|
||
}
|
||
```
|
||
|
||
**iOS reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. It does NOT read `result_photos` — those are web-only resolution photos.**
|
||
|
||
### Issue API Scope Rules
|
||
|
||
- **Inspector:** `GET /issues` returns issues where `assigned_to == current_user.id` **OR** `reported_by == current_user.id`.
|
||
- **Admin / Director / Project Manager:** `GET /issues` returns all non-resolved issues (default) or filtered by `?status=`.
|
||
- `GET /issues/<id>` and `PATCH /issues/<id>/status` and `PATCH /issues/<id>/photos` all enforce the same combined inspector check.
|
||
|
||
### Photo Upload Flow (multi-photo issues)
|
||
|
||
```
|
||
1. iPad calls POST /api/v1/photos/upload × N → gets N server_path strings
|
||
2. iPad calls POST /api/v1/issues → sends photo_path = paths[0]
|
||
result_photos = paths[1:] (stored in mobile_photo_paths)
|
||
3. iPad calls PATCH /api/v1/issues/<id>/photos → sends result_photos = paths[1:]
|
||
(PATCH is belt-and-suspenders for race safety)
|
||
```
|
||
|
||
Web template shows `photo_path` + `mobile_photo_paths` together under **"Photo Evidence"**. `result_photos` (resolution photos from web form) appears under **"Resolution Details"**.
|
||
|
||
### Facility deduplication
|
||
|
||
`pullReferenceData()` deduplicates the `/api/v1/facilities` response by `id` before upserting. The server may return the same facility ID more than once (one row per contract assignment). Without deduplication, the same building appears twice in every picker. The dedup uses a `seenFacilityIds = Set<Int>()` filter on the iOS side AND the upsert map (`facilityMap`) on the server side.
|
||
|
||
### Idempotency Pattern
|
||
|
||
All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt, check for existing record and return `{ 'duplicate': True }` without inserting. Web-created records have `mobile_local_id = NULL`.
|
||
|
||
### Score Calculation (Server-Side)
|
||
|
||
`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly. Rating value `0` = unanswered → excluded. Returns `float` 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=<DIGEST_SECRET>`
|
||
|
||
---
|
||
|
||
## 16. Rate Limiting
|
||
|
||
```python
|
||
limiter = Limiter(
|
||
key_func = get_remote_address,
|
||
default_limits = [],
|
||
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
|
||
)
|
||
```
|
||
|
||
**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`.
|
||
|
||
---
|
||
|
||
## 17. Alembic Migration Chain
|
||
|
||
**Current HEAD:** `phase40_support_chat_kb` (38 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 → phase40_support_chat_kb ← HEAD
|
||
```
|
||
|
||
### phase40_support_chat_kb
|
||
|
||
Creates three tables backing support chat persistence and the AI knowledge base:
|
||
- `support_chat_sessions` — one row per customer chat thread (`customer_id`, `title`, `created_at`, `last_msg_at`)
|
||
- `support_chat_messages` — individual turns (`session_id` CASCADE, `role` user/assistant, `content`, `created_at`)
|
||
- `support_knowledge` — admin-curated chatbot context entries (`title`, `body`, `active`, `created_by`, `created_at`, `updated_at`)
|
||
|
||
All three tables guarded with `INFORMATION_SCHEMA` table-existence checks — safe to re-run. `down_revision = 'phase39_issue_handler_type'`.
|
||
|
||
**Deploy order:**
|
||
```bash
|
||
flask db upgrade
|
||
sudo systemctl restart gunicorn
|
||
```
|
||
|
||
### 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/<token>/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/<token>` — 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/<id>/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 <slug>
|
||
# 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('<blueprint>.')` in each nav `<a>` 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 `<form>` tags** — browsers silently discard inner forms
|
||
|
||
### Real-Time
|
||
**SSE banned.** All "live" updates use polling.
|
||
|
||
### Issue Photo Evidence Display (view.html)
|
||
|
||
`view.html` shows `photo_path` and `mobile_photo_paths` together under the **"Photo Evidence"** heading using a `d-flex flex-wrap gap-2` grid. `result_photos` (resolution photos) appear separately under **"Resolution Details"**. Do not merge these sections — they have different semantic meaning.
|
||
|
||
### Contract → Facility Cascade (filter bars and create form)
|
||
|
||
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
|
||
|
||
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar), `reports/issues_aging.html` (filter bar), `reports/sla_compliance.html` (filter bar).
|
||
|
||
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
|
||
|
||
The **Reports R1 (Issues Aging) and R2 (SLA Compliance)** filter bars include a Contract cascade dropdown that is **client-side only** — selecting a contract calls `GET /inspections/facilities_for_project/<id>` to narrow the facility list in the browser; the actual DB filter still uses only `facility_id`. The route passes `projects` to the template (all active projects, scoped to the customer's assigned facilities when the role is `customer`). The cascade JS is guarded by `{% if projects %}` so it is omitted for empty lists (e.g. a customer with no facility assignments).
|
||
|
||
**Customer role — contract filter scoping:** In `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()`, the `projects` list passed to the template is scoped to contracts whose facilities overlap the customer's assigned facility set. Non-customer roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
||
|
||
### Customer Dashboard — "Your Facilities" Panel
|
||
|
||
Rendered in `dashboard.html` for `current_user.role == 'customer'`. Uses a Bootstrap card grid instead of a table:
|
||
|
||
- **Grid:** `col-12 col-sm-6 col-lg-4` — 3 per row on large, 2 on medium, 1 on small.
|
||
- **Collapse (> 9 facilities):** First 9 cards are shown; a "Show all N facilities" toggle reveals the rest. Controlled by inline JS (`toggleFacilities` button, `VISIBLE = 9` constant).
|
||
- **Live search (> 6 facilities):** A `#facilitySearch` text input filters `.facility-col` cards in real time by matching against the card's full text content. The show-more bar hides while a search query is active.
|
||
- **Count badge:** The card header always shows the total facility count as a `badge bg-secondary rounded-pill`.
|
||
- The JS block is only emitted when `customer_facilities|length > 9`; the search input is only emitted when `customer_facilities|length > 6`.
|
||
|
||
### Issue Comments — Visibility & Authorship
|
||
|
||
- Comments live in the left column of `issues/view.html` under the issue description, rendered as chat bubbles.
|
||
- Each bubble shows: colored avatar circle (color keyed to `author.id % 7`), display name, role badge (Staff / Customer), `is_customer_visible` badge (staff-only view), status-at-time badge, timestamp, and body.
|
||
- **Staff commenting:** A hidden checkbox `name="is_customer_visible"` in the Add Comment form defaults to unchecked (staff-only). Checking it marks the comment visible to customers.
|
||
- **Customer commenting:** Only shown when `can_customer_comment = is_following or issue.reported_by == current_user.id`. Customer POST bypasses `IssueUpdateForm`; the route sets `is_customer_visible=True` unconditionally.
|
||
- **Read filtering:** `GET issues/view` passes `filter_by(is_customer_visible=True)` to customers; staff receive all comments.
|
||
|
||
### Inspection List Filters
|
||
|
||
`inspections.index()` accepts five additional query params: `date_from`, `date_to` (ISO date strings), `score_min`, `score_max` (0–100 floats), `inspector_id` (int). Inspector filter is suppressed when the viewer has the `inspector` role (they always see their own only). The `inspectors` variable is passed to the template only for non-inspector roles so the dropdown is conditionally rendered.
|
||
|
||
### Inspection List — PDF Export & Filter State Preservation
|
||
|
||
`GET /inspections/export-list-pdf` — same filter logic as `index()`, passes current filters as `filter_summary` string to `generate_inspections_list_pdf()`. Logs `ACTION_EXPORT`.
|
||
|
||
**Filter state on back-navigation:** `list.html` adds class `insp-list-link` to every View/Continue button. On click, JS saves `window.location.href` to `sessionStorage['insp_list_back_url']`. `view.html` reads this key on load and updates the back button `href` so returning from a detail view restores the previous filter state.
|
||
|
||
### Issues List — ID Filter, Date Filter & PDF Export
|
||
|
||
`issues.index()` accepts three additional query params: `issue_id` (exact match on `Issue.id`), `date_from`, `date_to` (ISO date strings applied to `Issue.reported_at`). The `date_to` end is expanded to `23:59:59` so the whole day is included.
|
||
|
||
`GET /issues/export-list-pdf` — same scope + filter logic as `index()`, applies SLA post-filter for `?sla=` param (SLA is computed in Python, not stored). Calls `generate_issues_list_pdf()`.
|
||
|
||
Both `index()` and `export_list_pdf()` carry `date_from` / `date_to` in pagination links and the unfollow-next URL.
|
||
|
||
### Inspector Performance — Excel Export
|
||
|
||
`GET /reports/export/inspector-performance` generates a `.xlsx` with two sheets:
|
||
- **Performance Summary** — all inspector KPIs, color-coded cells, totals row
|
||
- **Inspection Detail** — individual inspection records for the period
|
||
|
||
Accepts `start`, `end`, `inspector_id` query params. Logs an `EXPORT` audit action. Uses `openpyxl`.
|
||
|
||
### Dashboard — Grouped Sections
|
||
|
||
The dashboard cards are organised into two labelled sections separated by a divider rule:
|
||
|
||
**Inspections section** (all roles see first 2; staff see all 4):
|
||
1. Today's Inspections — links to `inspections.index` filtered by today
|
||
2. Submitted Today — links to `inspections.index` with `status=completed` + today's date
|
||
3. Stale In-Progress — inspections with `status=in_progress` AND `inspection_date < now - 24h`; links to `inspections.index?status=in_progress`
|
||
4. Pending Follow-ups — inspections with `follow_up_required=True`; links to `inspections.index?status=follow_up`
|
||
|
||
**Issues section** (customers see first 3; staff see all 5):
|
||
1. Open Issues — `status=open` with severity breakdown badges
|
||
2. Issues Opened Today — links to `issues.index` with `date_from=today&date_to=today` (uses the date filter added to `issues.index`)
|
||
3. Resolved Today — `status=resolved` + today's date range
|
||
4. Pending Verification — `status=pending_verification`
|
||
5. Unassigned Open — `status=open` issues with no `assigned_to`
|
||
|
||
Each card has a subtitle line explaining what it counts. Section dividers use `d-flex align-items-center gap-2` with a `<div style="flex:1;height:1px;background:#e2e8f0;">` rule.
|
||
|
||
**Inspector Activity table** follows the cards for admin/director/PM: all active inspectors, today's completed inspection count per inspector, progress bar scaled to `max_count`. Green row highlight if count > 0.
|
||
|
||
### Reports — Navigation & New Pages
|
||
|
||
The **Reports** main-nav item is positioned second (right after Dashboard). **Scheduled Reports** was removed from the main nav and is now a sub-nav tab inside Reports (visible to admin/director/PM).
|
||
|
||
All report pages include `{% include 'reports/_subnav.html' %}` as the first element inside `{% block content %}`. The sub-nav tab visibility is role-gated:
|
||
|
||
| Tab | Roles |
|
||
|---|---|
|
||
| Overview & Trends | All |
|
||
| Issues Aging | All |
|
||
| SLA Compliance | All |
|
||
| Follow-up Closure | admin, director, project_manager |
|
||
| Inspector Performance | admin, director |
|
||
| Scheduled Reports | admin, director, project_manager |
|
||
|
||
### Reports — Phase R1: Issues Aging (`/reports/issues-aging`)
|
||
|
||
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `1–3 days`, `3–7 days`, `1–4 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
|
||
|
||
**Contract cascade filter:** A client-side Contract `<select>` (no name attribute — not submitted) appears above the Facility dropdown. On change, JS calls `GET /inspections/facilities_for_project/<id>` to narrow the Facility list in-browser; clearing the contract restores all options. The route passes `projects` (all active, or scoped to customer facility set). The actual DB filter uses only `facility_id`.
|
||
|
||
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
|
||
|
||
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
|
||
|
||
### Reports — Phase R2: SLA Compliance (`/reports/sla-compliance`)
|
||
|
||
Loads resolved issues in the date range, computes `within_sla()` per issue (compares elapsed hours to `SLA_HOURS[severity]`). Produces:
|
||
- `overall_pct` — org/scope-wide compliance %
|
||
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
|
||
- `by_facility` — list sorted by compliance % descending
|
||
|
||
**Contract cascade filter:** Same client-side Contract → Facility cascade as R1. Route passes `projects`; DB filter uses only `facility_id`.
|
||
|
||
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
|
||
|
||
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
|
||
|
||
### Reports — Phase R3: Follow-up Closure (`/reports/followup-closure`)
|
||
|
||
`@supervisor_required`. Loads inspections with `follow_up_required=True` in date range. Determines which have been re-inspected via a separate `SELECT parent_inspection_id FROM inspections WHERE parent_inspection_id IN (...)` query — avoids iterating the dynamic `follow_ups` relationship.
|
||
|
||
Annotates each inspection with `insp._has_followup = insp.id in followed_up_ids` (transient Python attribute, not an ORM column).
|
||
|
||
Excel export: `GET /reports/export/followup-closure` — one sheet with color-coded "Followed Up?" column (green/red).
|
||
|
||
### Reports — Phase R4: Customer Facility PDF Summary
|
||
|
||
`GET /reports/facility/<id>/summary-pdf` — available to all roles with facility access (customer scope enforced). Accepts `days` param (30/60/90/180/365; default 90). Calls `generate_facility_summary_pdf()` from `pdf_export.py`. Downloads directly as a PDF attachment.
|
||
|
||
A **PDF Summary** button was added to `reports/scorecard.html` alongside the existing Full Report and period selector buttons.
|
||
|
||
### Support Chat — Customer UX
|
||
|
||
`GET /support/chat` — customer only. Accepts optional `?session_id=N` to reload a prior conversation.
|
||
|
||
Renders:
|
||
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks). Greeting is hidden when loading a prior session (`{% if not db_history %}`).
|
||
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. Hidden when a prior session is loaded. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
||
- **Chat history is DB-backed (phase40).** Prior turns are rendered server-side on page load from `db_history` (list of `SupportChatMessage`). The JS variable `let session_id` is seeded from `chat_session.id` (null for new chats). AJAX sends only `{ message, session_id }` — **no history array** (rule 95). Server returns `{ reply, session_id }` and the JS stores/reuses `session_id` across subsequent messages.
|
||
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
|
||
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from `last_user_msg` JS variable (last message typed, not scanned from history array).
|
||
- "History" button links to `support.my_conversations` (list of all past sessions). "New Chat" link starts a fresh session (`/support/chat` with no `session_id`).
|
||
|
||
### Inspection Execute Page — UX Patterns
|
||
|
||
- **Photo upload-on-select**: `uploadPhotoField(input)` fires immediately on `<input type="file">` change. XHR to `POST /<id>/upload-photo`. On success, the server path is written to `<input type="hidden" id="field_<fid>_server_path">` and a `<img id="thumb_<fid>">` is shown.
|
||
- **Flag-issue as offcanvas**: `#flagIssuePanel` Bootstrap offcanvas contains the flag-issue form. On submit, `saveDraft()` fires first, then the form is sent via `fetch()` FormData, then the page reloads. Never navigates away — photos are never lost.
|
||
- **Auto-save draft**: `setInterval(autoSave, 60000)` calls the save-draft endpoint every 60 s. `#autoSaveStatus` in the footer shows the last-saved timestamp.
|
||
- **Progress indicator**: Counts answered non-zero rating fields vs. total; updates `#progressLabel` in the footer on every change.
|
||
- **Scroll restore**: `window.scrollY` saved to `sessionStorage` on `beforeunload`; restored on `load`.
|
||
|
||
---
|
||
|
||
## 19. Infrastructure
|
||
|
||
### Gunicorn
|
||
```python
|
||
bind = "127.0.0.1:8000"
|
||
workers = multiprocessing.cpu_count() * 2 + 1
|
||
worker_class = "sync"
|
||
timeout = 30
|
||
```
|
||
|
||
### Application Logging
|
||
- `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB)
|
||
- `StreamHandler` → stdout (journalctl)
|
||
|
||
### Nginx
|
||
- `client_max_body_size 50M`
|
||
- Passes `X-Forwarded-For`
|
||
- **Multi-tenant wildcard block** (alongside the existing single-domain block):
|
||
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
server_name *.jqc.app jqc.app;
|
||
|
||
location / {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_set_header Host $host; # resolver reads this
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
}
|
||
```
|
||
|
||
**TLS for subdomains:** wildcard cert (`*.jqc.app`) via DNS-01 challenge (certbot + DNS plugin). HTTP-01 works for individual subdomains only and cannot auto-renew a wildcard. Custom-domain TLS via Caddy on-demand TLS (MT-6).
|
||
|
||
### Control-plane environment file
|
||
|
||
**All** control/provisioning env vars must live in one canonical file. Both the systemd unit and CLI sessions must source the same file — a key mismatch between them causes `cryptography.fernet.InvalidToken` at request time.
|
||
|
||
```bash
|
||
# /etc/jqc/control.env (chmod 640, chown root:jqc)
|
||
CONTROL_DATABASE_URL=mysql+pymysql://jqc_control:<pw>@127.0.0.1/jqc_control
|
||
CONTROL_FERNET_KEY=<generated key — no quotes>
|
||
PROVISION_DB_URL=mysql+pymysql://jqc_provisioner:<pw>@127.0.0.1/
|
||
TENANT_BASE_DOMAIN=jqc.app
|
||
MULTI_TENANT_ENABLED=true
|
||
```
|
||
|
||
```ini
|
||
# systemd unit [Service]
|
||
EnvironmentFile=/etc/jqc/control.env
|
||
```
|
||
|
||
```bash
|
||
# CLI sessions
|
||
set -a; . /etc/jqc/control.env; set +a
|
||
```
|
||
|
||
### Recommended Cron Schedule
|
||
```bash
|
||
0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \
|
||
-d "token=SECRET&frequency=daily"
|
||
*/30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \
|
||
-d "token=SECRET"
|
||
0 3 * * * curl -s -X POST https://your-domain.com/notifications/cleanup-tokens \
|
||
-d "token=SECRET"
|
||
0 8 * * * curl -s -X POST https://your-domain.com/scheduled-reports/run \
|
||
-d "secret=SECRET"
|
||
0 8 * * * curl -s -X POST https://your-domain.com/notifications/check-score-trends \
|
||
-d "token=SECRET"
|
||
0 9 * * * curl -s -X POST https://your-domain.com/notifications/trial-reminders \
|
||
-d "token=SECRET"
|
||
0 10 * * * curl -s -X POST https://your-domain.com/notifications/dunning-reminders \
|
||
-d "token=SECRET"
|
||
0 6 * * * curl -s -X POST https://your-domain.com/inspection-schedules/run \
|
||
-d "token=SECRET"
|
||
```
|
||
|
||
---
|
||
|
||
## 20. Known Constraints & Hard Rules
|
||
|
||
| # | Rule | Rationale |
|
||
|---|---|---|
|
||
| 1 | **No SSE** | Exhausted Gunicorn sync worker pool |
|
||
| 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs |
|
||
| 3 | **3-step MySQL ENUM changes** | Skipping causes data loss |
|
||
| 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail |
|
||
| 5 | **`csrf.exempt()` on each child blueprint individually** | `csrf.exempt(api_bp)` does NOT cascade; Flask-WTF checks leaf blueprint object only |
|
||
| 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators |
|
||
| 7 | **Score 0 = unanswered** | Excluded from calculation — not the same as scoring zero |
|
||
| 8 | **12-column grid in PDF** | Must not collapse in print/PDF |
|
||
| 9 | **No nested `<form>` tags** | Browsers silently discard inner forms |
|
||
| 10 | **`log_action()` after `db.session.commit()`** | Entity ID must exist before audit capture |
|
||
| 11 | **`db.session.get(Model, id)` not `Model.query.get(id)`** | SQLAlchemy 2.x deprecation |
|
||
| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement |
|
||
| 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 |
|
||
| 14 | **Email in background thread** | Never block HTTP response |
|
||
| 15 | **Open-redirect guards** | `safe_redirect_url()` in `app/utils/decorators.py` |
|
||
| 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.12** | Use `INFORMATION_SCHEMA.STATISTICS` check |
|
||
| 17 | **`batch_alter_table` is SQLite-only** | Use direct `ALTER TABLE` for MySQL migrations |
|
||
| 18 | **Set `REDIS_URL` in production** | `memory://` is per-process; Gunicorn needs Redis for accurate shared counters |
|
||
| 19 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged |
|
||
| 20 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only |
|
||
| 21 | **`mobile_local_id` idempotency on all mobile write endpoints** | Network retries must not create duplicate records |
|
||
| 22 | **Photo upload before inspection/issue submission** | Server path must be known before the parent record is created |
|
||
| 23 | **Migration deploy before new `app/__init__.py`** | New init imports models referencing new columns; columns must exist first |
|
||
| 24–29 | *(iOS-specific — see iOS CLAUDE.md)* | |
|
||
| 30 | **Do NOT add an explicit `Issue.area` relationship** | `Area.issues` declares `backref='area'`, supplying `Issue.area` automatically. A second declaration raises `ConflictingBackreferences` at startup. |
|
||
| 31 | **Do not sync an issue when its parent `LocalInspection.syncStatus == "failed"`** | Submitting without `inspection_id` creates orphaned server records |
|
||
| 32 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes |
|
||
| 33–38 | *(field ID casting, photo sentinel, notify event_type, follow-up, OperationalError)* | See prior rule entries |
|
||
| 39 | **Inspector issue scope: assigned OR reported — web and API must match** | `issues.index()`, `issues.view()`, and all API issue endpoints (`GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos`) enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role |
|
||
| 40 | **`_issue_payload()` must return all documented fields** | iPad reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. Phase A–E added `result_notes`, `verified_at`, `verification_note`, `reported_by_name`, `area_name`, `assigned_to_name`. Omitting any field silently breaks the corresponding iPad display. |
|
||
| 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row |
|
||
| 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; use `~.any()` which emits `NOT EXISTS` |
|
||
| 43 | **`issues.index()` outerjoin must precede all filters** | Both customer-scope and facility_filter blocks reference `Area.facility_id` |
|
||
| 44 | **iPad evidence photos go to `mobile_photo_paths`, never `result_photos`** | `result_photos` is exclusively for resolution photos added via the web update form. Mixing them causes evidence photos to appear under "Resolution Details" on the web. |
|
||
| 45 | **`PATCH /issues/<id>/photos` is idempotent — merge, never overwrite** | Retry-safe: `merged = existing + [p for p in new_photos if p not in existing]` |
|
||
| 46 | **Facility deduplication in `pullReferenceData()` on iOS** | Server may return same facility ID multiple times; deduplicate before upsert using `seenFacilityIds = Set<Int>()` |
|
||
| 47 | **Magic-byte validation in `_save_photo()`** | Added post-phase-19. Reads 8 bytes before saving; rejects files that do not begin with a known image magic (`\xff\xd8\xff`, `\x89PNG`, `GIF87a`, `GIF89a`). Prevents MIME-type spoofing via extension-only checks. |
|
||
| 48 | **`upload_photo_ajax` endpoint on inspections blueprint** | `POST /<inspection_id>/upload-photo` with `@limiter.limit("30 per minute")`. Triggers on file selection (not form submit) so photos survive AJAX draft-save and page navigation. Stores in `inspection_photos/` subfolder; returns `{ok, path}`. |
|
||
| 49 | **Template schema snapshotted at submit time** | `execute()` POST stores `form_fields` list as `_template_schema` inside `inspection.notes` JSON. `view()` prefers this snapshot over the live template so historical inspection views remain correct if the template changes later. |
|
||
| 50 | **`mobile_local_id` UUID format validation on write endpoints** | `POST /api/v1/inspections` and `POST /api/v1/issues` validate `mobile_local_id` against `_UUID_RE` regex. Rejects non-UUID strings with HTTP 400. Prevents garbage values from being stored as idempotency keys. |
|
||
| 51 | **Security response headers via `@app.after_request`** | Sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, and a `Content-Security-Policy`. CSP includes `object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'` (all forms post same-origin, so `form-action 'self'` is safe). `script-src`/`style-src` still carry `'unsafe-inline'` — removing that needs a nonce migration across all inline scripts (larger follow-up, not done). **HSTS** (`Strict-Transport-Security: max-age=31536000`) is emitted only when the request is HTTPS (`request.is_secure` or `X-Forwarded-Proto: https`); `includeSubDomains` is intentionally omitted so a tenant custom domain never force-upgrades an unrelated customer subdomain. The panel (`control/panel/__init__.py`) mirrors these. Uses `setdefault` so API responses can override. |
|
||
| 52 | **Inspection `execute.html` offline-resilient photo flow** | Photos are uploaded immediately on file selection via `uploadPhotoField()` (XHR to `upload_photo_ajax`). Server path is stored in `<input type="hidden" id="field_<fid>_server_path">`. AJAX draft-save and flag-issue submission read these hidden fields so photos are never lost on navigation. |
|
||
| 53 | **Flag-issue panel is an offcanvas — not a page navigation** | Converted from a navigate-away flow to a Bootstrap offcanvas. Draft is saved first via `saveDraft()`, then the flag-issue form is submitted via `fetch()` FormData, then the page reloads. Eliminates the entire class of "photos lost on navigation" bugs. |
|
||
| 54 | **Bulk issue verification via `POST /issues/bulk-verify`** | `@supervisor_required`. Accepts `issue_ids` list from form. Skips issues not in `resolved` or `pending_verification` state. Calls `log_action()` after `db.session.commit()` per rule 10. |
|
||
| 55 | **Scheduled "issues" report groups by facility with SLA status** | `_build_report_data()` now produces `issues_by_facility` (list of `(facility_name, [(issue, sla), ...])`) and `sla_breached`/`sla_at_risk` counts alongside the flat `issues` list. CSV builder uses `resolved_facility` (not `area.facility`) to avoid crash when `area_id` is None. |
|
||
| 56 | **Customer role: `POST` to `issues.view` returns 403** | The `view()` route checks `request.method == 'POST'` inside the customer scope block and calls `abort(403)`. Customers have read-only access; the template already hides the update form, but server-side enforcement is required against crafted requests. |
|
||
| 57 | **Inspector contract scoping: `get_inspector_scope()` — strict, no fallback** | Inspectors with NO `InspectorAssignment` rows see nothing (empty list, not `None`). Returns `None` only for non-inspector roles. All routes and API endpoints that currently filter by `inspector_id` or `assigned_to/reported_by` must instead filter by the facility list returned by `get_inspector_scope()`. |
|
||
| 58 | **Inspector scope covers all data in contracted facilities, not just own work** | Facility list, inspection list, issue list — all scoped to contracted facilities. Dashboard personal stats (today's work, avg score, trend) additionally filter by `inspector_id` so the productivity view stays personal. Issues show ALL facility issues, not just assigned ones. |
|
||
| 59 | **`assign_inspector_contracts` route replaces the entire assignment set on POST** | The form sends the full checked list; existing assignments not in the POST body are deleted, new ones are inserted. Callers must always POST the complete desired set, not a diff. The page includes Select All / Deselect All buttons (JS-only, no server round-trip) and a live "N assigned" badge that updates on each checkbox change. |
|
||
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
|
||
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
|
||
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
|
||
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()` build the `projects` list differently for `customer` role. Inspections/issues use `CustomerAssignment` to get assigned `project_id` values. Reports use a join: `Project.query.join(Facility).filter(Facility.id.in_(customer_facility_ids))`. All other roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in any Contract filter dropdown. |
|
||
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. |
|
||
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
|
||
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
|
||
| 67 | **`display_name` in JS must use `\| tojson`, not inline Jinja interpolation** | `"Hi {{ name }}"` in a JS string literal breaks if `name` contains `"` or `\`. Use `var name = {{ name \| tojson }};` then concatenate. |
|
||
| 68 | **Support ticket customer replies revert status from `answered` → `open`** | When a customer posts a follow-up on an answered ticket, the route sets `ticket.status = 'open'` so admins see it in their open queue. Admin must manually close or re-answer. |
|
||
| 69 | **Customer issue create: `assigned_to` field hidden, `facility_id` scoped to `get_customer_scope()`** | `issues.create()` detects `role == 'customer'`, scopes facilities to the customer's assigned set, sets `staff = []` for the assigned_to dropdown, and hides the field in `form.html`. `IssueForm.facility_id.choices` must still include all active facilities so POST validation passes. |
|
||
| 70 | **`notify()` does NOT commit — caller must `db.session.commit()` after all `notify()` calls** | `notify()` adds a `Notification` row to the session but leaves the commit to the caller. The support helpers (`_notify_admins_new_ticket`, `_notify_customer_reply`, `_notify_admins_customer_reply`) each call `db.session.commit()` after the `notify()` loop. |
|
||
| 71 | **`CONTROL_FERNET_KEY` must be identical between CLI sessions and the Gunicorn service** | Provisioning encrypts tenant creds; the app decrypts them at request time. A key mismatch causes `cryptography.fernet.InvalidToken`. Canonical source: `/etc/jqc/control.env` — sourced by both `EnvironmentFile=` in the systemd unit and `set -a; . /etc/jqc/control.env; set +a` in CLI sessions. |
|
||
| 72 | **`MULTI_TENANT_ENABLED` must be flipped only after tenant-zero (LT) is registered** | The resolver gates all traffic by Host. If LT's domains aren't in `tenant_domains` when the flag goes `true`, LT's own traffic gets a 404 "Workspace not found" page. Register LT first via `register-tenant-zero`, confirm with `curl -H "Host: lts.jqc.app" http://127.0.0.1:8000/`, then flip the flag. |
|
||
| 73 | **Tenant DB passwords use `_gen_password()` — not `secrets.token_urlsafe()`** | MySQL `validate_password` MEDIUM policy requires lower + upper + digit + special. `token_urlsafe` is alphanumeric-only and fails intermittently. `_gen_password()` guarantees all four character classes. |
|
||
| 74 | **`bootstrap_tenant()` for fresh DBs; `upgrade_tenant()` for incremental** | Fourteen phase migrations are unguarded. Running the full chain on an empty DB (from the baseline) causes duplicate-column errors. `bootstrap_tenant()` runs the baseline to HEAD then stamps — phases skipped. `upgrade_tenant()` is for phase33+ incremental upgrades on already-provisioned DBs. |
|
||
| 75 | **`delete_tenant()` + `_add_domains()` are retry-safe** | `_add_domains` uses `_upsert_domain()` (delete-then-insert) to handle orphan rows from failed partial runs. `delete_tenant()` also purges by derived domain string, not only by `tenant_id`, catching orphans whose parent tenant row was rolled back. |
|
||
| 76 | **`register-tenant-zero` never bootstraps and never drops the DB** | `register_tenant_zero()` only reads the existing head, inserts control rows, and maps domains. `delete_tenant()` on tenant-zero must never use `--drop-db` — the guard checks `db_name == db_name_for(slug)` and refuses non-provisioner-named DBs (LT's DB name is `jqc_lt`, not `jqc_lts`). |
|
||
| 84 | **Device registration is consolidated on `DeviceToken` / `api_device_tokens` — one handler only** | RESOLVED. There is exactly one `POST /api/v1/devices/register`, in `app/api/auth.py` (blueprint `api_auth`); it upserts `DeviceToken` (device_id, device_name, app_version, ios_version, apns_token, last_seen_at) which the admin Devices page reads. The former duplicate `api_devices` blueprint (`app/api/devices.py`) and the orphaned `DeviceRegistration` model / `device_registrations` table were **deleted** — that path wrote to a table phase31/32 drop. Do not reintroduce a second `/devices/register` route or a `device_registrations`-backed model. |
|
||
| 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. **The apex check runs BEFORE the `MULTI_TENANT_ENABLED` gate** — it must work in single-tenant mode too, otherwise the app serves its default database (tenant-zero) for the apex host and the landing page never shows. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through, and `TENANT_BASE_DOMAIN` set correctly in the app environment. |
|
||
| 86 | **Free plan is free-forever, not a trial** | `signup.index()` passes `trial_days=0` for `plan_code == 'free'`; `create_tenant()` then sets `subscription_status='active'` (no `trial_ends_at`) so `_billing_gate()` never blocks it. Paid plans keep the 14-day trial (`trial_days=14`). The welcome email adapts via `trial_note` and hides the trial row when `trial_ends_at` is blank. Do not reintroduce a hardcoded `trial_days=14` in the signup path. |
|
||
| 87 | **MFA is opt-in, TOTP-based, with hashed one-time recovery codes** | `app/utils/mfa.py` (data plane) and `control/mfa.py` (panel) are pure-logic mirrors — keep them in sync (same rule class as `time_utils`). The login challenge (`/auth/mfa`, panel `/mfa`) fires for ANY account with `mfa_enabled=1`; `login_user()`/`session['sa_id']` is deferred until the code passes. Recovery codes are stored ONLY as werkzeug hashes and are single-use (consumed on match). Disable requires a current TOTP code OR the password. **Lock-out escape hatch:** because MFA is per-account opt-in, the recovery path is the primary unlock; the operational last resort is a DB update `UPDATE users SET mfa_enabled=0, mfa_secret=NULL, mfa_recovery_codes=NULL WHERE username=...` (or the same on `superadmins`). Do not store `mfa_secret`/recovery codes in plaintext, and do not skip the deferred-login pattern. |
|
||
| 89 | **Vendor work-order pages are public and token-authorized — the token IS the credential** | `GET/POST /work-orders/<token>` have NO `@login_required`; the unguessable `secrets.token_urlsafe(32)` token is the sole authorization, so never render one in any staff-visible page, log line, or list except in the contractor's own emailed link. Rate-limited (`60/hr` view, `20/hr` update). The public page shows only scoped issue details (facility, area, description, severity, staff message) — never internal notes/comments/assignees. State transitions are one-way and guarded (`sent→acknowledged→completed`); a completed order ignores further actions. Completing an order sets the parent issue to `pending_verification` (staff still sign off — the vendor cannot self-resolve). In MT mode the link resolves to the right tenant by Host, so the route is NOT tenant-exempt. |
|
||
| 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. |
|
||
| 90 | **Per-contract recipients dispatch INSIDE `notify_by_matrix()` — never call `_notify_project_recipients()` from routes** | phase37. Contract-scoped recipients (`ProjectNotificationRecipient`) are dispatched automatically at the end of `notify_by_matrix()`, after matrix roles + global custom emails, with dedup against both. The contract is resolved from `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`; events fired without any facility context reach matrix recipients only. New `notify_by_matrix()` call sites should pass `facility_id` (or `issue_id`/`inspection_id`) so contract recipients fire. The `score_alert` cron call in `sla.py` now passes `facility_id=fid` for this reason (side effect: if the matrix ever enables `customer` for `score_alert`, customers are facility-scoped instead of org-wide — a strict improvement). Staff recipients use `respect_preferences=False` (contract config is the authority, same as matrix broadcasts). |
|
||
| 91 | **Facility QR scan page is public and token-authorized — counts + scores ONLY** | phase38, same authorization class as rule 89: `GET /f/<token>` has NO `@login_required`; the unguessable `facilities.qr_token` is the sole credential, because QR posters hang in public hallways. The page must NEVER render free-text issue descriptions, inspector/staff names, photos, or comments — only aggregate counts, scores, dates, template names, and severity/SLA counts. Rate-limited `60/hr`. Inactive facilities 404. The hybrid full-view button appears only when `current_user` is authenticated AND their role scope covers the facility (`_can_view_full()` — staff always; inspector/customer via scope utils); the internal page re-enforces scope anyway. QR URLs are built from `request.host_url` (rule 64 pattern) so each tenant's posters carry its own domain — the route resolves by Host and is NOT tenant-exempt. `qr_svg()` lives in `app/utils/qr.py` (general-purpose; the TOTP-specific `mfa.qr_svg()` mirrors stay untouched). Rotate a leaked poster with `POST /facilities/<id>/qr/regenerate`. |
|
||
| 92 | **`POST /f/<token>/report` creates issues with `reported_by=None` — honeypot protects it** | phase39. The occupant report endpoint shares the same authorization model as rule 91 (token = credential, no login). The honeypot field (`name="website"`, CSS-hidden, `position:absolute;left:-9999px`) silently drops bot submissions by redirecting to the success URL without creating an issue. Rate-limited `5/hr` per IP. The notification fires `notify_by_matrix('issue_created', issue_id=..., facility_id=...)` so admins are notified via the standard matrix. Do not add login gates, photo upload, or internal fields (assignee, comments) to this form — it is intentionally minimal. |
|
||
| 93 | **ProxyFix must wrap `app.wsgi_app` — without it, rate limiting and fail2ban are broken** | `app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)` reads `X-Forwarded-For` set by Nginx. Without it, `get_remote_address()` returns `127.0.0.1` for every request — Flask-Limiter shares one counter across all users and fail2ban can never ban an attacker's real IP. Always set in `create_app()` immediately after `app = Flask(__name__)`. |
|
||
| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. |
|
||
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
||
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
|
||
| 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. |
|
||
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
|
||
|
||
---
|
||
|
||
## 21. Change Philosophy
|
||
|
||
1. **Surgical, additive patches** — smallest possible change to achieve the goal
|
||
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise
|
||
3. **Never remove existing functionality** unless explicitly directed
|
||
4. **Log all create/update/delete actions** via `log_action()`
|
||
5. **Migration existence checks** — all migrations safe to re-run
|
||
6. **Full file contents for 1–3 file changes**; deployment map for larger changesets
|
||
7. **Explicit deploy instructions** — migration steps separated from code steps
|
||
8. **Root cause analysis** on errors — never apply temporary workarounds
|
||
|
||
---
|
||
|
||
## 22. Multi-Tenant Architecture (MT-0 → MT-8)
|
||
|
||
See `MULTI_TENANT_PLAN.md` for the full phased roadmap. This section summarises what is built and operational.
|
||
|
||
### Model
|
||
|
||
Shared codebase + **database-per-tenant**. One Flask/Gunicorn process serves all tenants. `before_request` resolves the `Host` header → tenant → per-tenant MySQL database. All existing `db.session` calls route transparently — zero changes to models or routes.
|
||
|
||
### Control plane (`control/`)
|
||
|
||
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||
|
||
| Module | Purpose |
|
||
|---|---|
|
||
| `base.py` | `ControlBase`, `control_session()` context manager, engine from `CONTROL_DATABASE_URL` |
|
||
| `models.py` | `Plan`, `PlanFeature`, `Tenant`, `TenantDomain`, `Superadmin`, `ProvisioningJob`, `TenantAudit` |
|
||
| `crypto.py` | Fernet encrypt/decrypt for `tenants.db_password_enc` |
|
||
| `seed.py` | Idempotent plan seeder (Free / Starter / Pro / Enterprise) |
|
||
| `cli.py` | `seed`, `create-superadmin`, `list-plans` |
|
||
| `tenant_migrate.py` | `bootstrap_tenant()`, `upgrade_tenant()`, `chain_head()`, `current_revision()` + CLI |
|
||
| `provision.py` | `create_tenant()`, `register_tenant_zero()`, `delete_tenant()` + CLI |
|
||
| `panel/` | Standalone Flask app for superadmin — see MT-4 below |
|
||
|
||
### Superadmin control panel (MT-4)
|
||
|
||
Separate Flask WSGI app at `admin.jqc.app`, Gunicorn port 8001, `jqc-panel.service` systemd unit.
|
||
|
||
**Critical Nginx ordering:** the `admin.jqc.app` server block must appear **before** the `*.jqc.app` wildcard block. If the wildcard catches `admin.jqc.app` first, requests go to port 8000 (main app) which returns 404 "Workspace not found".
|
||
|
||
**Impersonation:** panel generates HMAC-SHA256 signed token (`PANEL_IMPERSONATE_KEY`, TTL 60 s) → redirects to `/auth/impersonate?token=<t>` on the tenant → main app validates + sets `session['impersonating_tenant_id']` → tenancy middleware short-circuits Host resolution → "End impersonation" banner clears key.
|
||
|
||
### Data-plane changes (`app/`)
|
||
|
||
| File / Package | Change |
|
||
|---|---|
|
||
| `app/__init__.py` | `db = SQLAlchemy(session_options={'class_': RoutingSession})` + `init_tenancy(app)` + `inject_tenant_branding()` context processor + `hex_to_rgb` Jinja2 filter + `tenant_settings` blueprint |
|
||
| `config.py` | `MULTI_TENANT_ENABLED` (default `false`) + pool tunables |
|
||
| `app/tenancy/` | MT-1: resolution + routing. MT-5: `gates.py`, `quota.py`, extended `context.py` + `resolver.py` |
|
||
| `app/models/tenant_settings.py` | MT-7: per-tenant branding settings (one row per tenant DB) |
|
||
| `app/routes/tenant_settings.py` | MT-7: `/settings/` blueprint — branding, plan view, domain mgmt |
|
||
| `app/templates/tenant_settings/` | MT-7: branding, plan, domains templates |
|
||
| `app/templates/_quota_warning.html` | MT-5: reusable quota exceeded banner partial |
|
||
| `app/templates/base.html` | MT-7: tenant logo/name in navbar, CSS colour vars injection |
|
||
|
||
### Plan tiers
|
||
|
||
| Axis | Free | Starter | Pro | Enterprise |
|
||
|---|---|---|---|---|
|
||
| Max users | 3 | 15 | 50 | unlimited |
|
||
| Max facilities | 2 | 10 | 50 | unlimited |
|
||
| Inspections / month | 50 | 500 | 5 000 | unlimited |
|
||
| Issues / month | 50 | 500 | 5 000 | unlimited |
|
||
| Mobile API | ✗ | ✓ | ✓ | ✓ |
|
||
| Scheduled reports | ✗ | ✗ | ✓ | ✓ |
|
||
| Branding | ✗ | ✗ | ✓ | ✓ |
|
||
| Custom domain | ✗ | ✗ | ✓ | ✓ |
|
||
| Subdomain | ✓ | ✓ | ✓ | ✓ |
|
||
|
||
Quota-exceed behaviour: **soft warn** — allow submit, flag for upgrade, never reject.
|
||
Feature gate (`@feature_required`): hard 403 — route is blocked.
|
||
Both decorators are inert when `MULTI_TENANT_ENABLED=false`.
|
||
|
||
### Gate decorator stack order
|
||
|
||
```python
|
||
@bp.route(...)
|
||
@login_required # 1 — must be authenticated
|
||
@admin_required # 2 — role check (if applicable)
|
||
@feature_required('x') # 3 — plan feature gate (hard 403)
|
||
@quota_soft_check('y') # 4 — quota warn (never blocks)
|
||
def my_route(): ...
|
||
```
|
||
|
||
### Tenant self-service (MT-7) — `/settings/`
|
||
|
||
| Tab | Route | What it does |
|
||
|---|---|---|
|
||
| Branding | `/settings/branding` | Company name, logo upload, primary/accent colours, support email |
|
||
| Plan & Usage | `/settings/plan` | Read-only plan info, live quota progress bars |
|
||
| Domains | `/settings/domains` | List domains, request custom domain (TXT/CNAME verification), delete pending requests |
|
||
|
||
`TenantSettings.get_or_default()` returns safe defaults when no row exists — zero migration burden for tenant-zero.
|
||
|
||
### Provisioner MySQL account (required grants)
|
||
|
||
```sql
|
||
GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION;
|
||
GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost';
|
||
FLUSH PRIVILEGES;
|
||
```
|
||
|
||
### CLI quick-reference
|
||
|
||
```bash
|
||
# Source env first — CONTROL_DATABASE_URL not in interactive shell by default
|
||
set -a; . /etc/jqc/control.env; set +a
|
||
|
||
# Control schema + plans + first superadmin (run once)
|
||
alembic -c control/migrations/alembic.ini upgrade head
|
||
python -m control.cli seed
|
||
python -m control.cli create-superadmin --username admin --email you@example.com
|
||
|
||
# Adopt existing LT database as tenant-zero (run once, no data move)
|
||
python -m control.provision register-tenant-zero \
|
||
--slug lts --name "LT Services" --plan enterprise \
|
||
--db-host 127.0.0.1 --db-name <LT_DB> --db-user <LT_USER> --db-password '<pw>' \
|
||
--custom-domain jqc.ltservicesinc.com --base-domain jqc.app
|
||
|
||
# Provision a new tenant
|
||
python -m control.provision create-tenant \
|
||
--slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com
|
||
|
||
# Delete / deregister a tenant
|
||
python -m control.provision delete-tenant --slug ztest --drop-db --yes
|
||
python -m control.provision delete-tenant --slug lts --yes # adopted DB — no --drop-db
|
||
|
||
# Migration status + upgrades
|
||
python -m control.tenant_migrate heads
|
||
python -m control.tenant_migrate current --tenant all
|
||
python -m control.tenant_migrate upgrade --tenant all # incremental (phase33+)
|
||
python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
|
||
```
|
||
|
||
### Enable multi-tenancy (cutover sequence)
|
||
|
||
```bash
|
||
# 1. Register LT as tenant-zero (see above)
|
||
# 2. Smoke-test routing (flag still off)
|
||
curl -sI -H "Host: lts.jqc.app" http://127.0.0.1:8000/ | head -2
|
||
curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2
|
||
# 3. Wildcard DNS: *.jqc.app A <SERVER_IP>
|
||
# 4. Nginx config — admin.jqc.app block BEFORE *.jqc.app wildcard block (see §19)
|
||
# 5. Wildcard TLS cert (DNS-01, certbot)
|
||
# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true
|
||
# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc jqc-panel
|
||
```
|
||
|
||
### Known MT-specific gotchas (rules 71–83)
|
||
|
||
| # | Rule |
|
||
|---|---|
|
||
| 71 | `CONTROL_FERNET_KEY` must be identical between CLI sessions and Gunicorn — use the same `/etc/jqc/control.env` |
|
||
| 72 | `MULTI_TENANT_ENABLED` must be flipped only after tenant-zero is registered and domains verified |
|
||
| 73 | Tenant DB passwords use `_gen_password()` — MySQL `validate_password` MEDIUM requires lower+upper+digit+special |
|
||
| 74 | `bootstrap_tenant()` for fresh DBs; `upgrade_tenant()` for incremental (phase33+) |
|
||
| 75 | `delete_tenant()` + `_add_domains()` are retry-safe |
|
||
| 76 | `register-tenant-zero` never bootstraps and never drops the DB |
|
||
| 77 | `CONTROL_DATABASE_URL` not in interactive shell — always `set -a; . /etc/jqc/control.env; set +a` before CLI |
|
||
| 78 | Nginx `admin.jqc.app` must be a separate `server {}` block before `*.jqc.app` — exact name wins only in its own block |
|
||
| 79 | `jqc.app` apex has no registered tenant — Nginx **proxies** it to the app (port 8000) and the tenant middleware serves the public landing page (`landing.index`) for `/`. (Was a server-level 301 to `lts.jqc.app`; changed when the marketing landing page was added — see rule 85 + §10 of MULTI_TENANT_PLAN.md.) |
|
||
| 80 | `@feature_required` before `@quota_soft_check` in decorator stack — no point counting if feature is blocked |
|
||
| 81 | `TenantSettings.get_or_default()` returns a transient (non-persisted) default instance — `db.session.add(row)` required before first save |
|
||
| 82 | `inject_tenant_branding()` context processor wraps the DB call in try/except — branding failure must never break page rendering |
|
||
| 83 | `hex_to_rgb` Jinja2 filter required for `--bs-primary-rgb` CSS var — register in `app/__init__.py` |
|
||
|
||
---
|
||
|
||
## 23. Billing System (MT-8)
|
||
|
||
### Overview
|
||
|
||
Stripe-backed subscription billing. Controlled by `BILLING_ENABLED` env var (default `false`). When enabled, a `_billing_gate()` runs in `init_tenancy()` before every request and enforces trial expiry and cancellation state.
|
||
|
||
### Key files
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `app/billing/__init__.py` | Blueprint registration |
|
||
| `app/billing/routes.py` | `subscribe`, `portal`, `webhook`, `suspended` routes |
|
||
| `app/billing/webhooks.py` | Stripe event handlers (`payment_failed`, `subscription_updated`, `subscription_deleted`) |
|
||
| `app/billing/emails.py` | `send_billing_email()` — HTML + plain text dunning emails in background thread |
|
||
| `app/templates/billing/` | Plan picker, billing banner partial, HTML email templates |
|
||
| `control/seed.py` | Reads `STRIPE_PRICE_*` from env and writes to `Plan.stripe_price_id` |
|
||
|
||
### Billing gate (`_billing_gate()` in `app/tenancy/middleware.py`)
|
||
|
||
| `subscription_status` | Behaviour |
|
||
|---|---|
|
||
| `None` or `active` | Allow through |
|
||
| `trial` (not expired) | Allow; set `g.billing_warning = 'trial_ending'` if ≤ 3 days remain |
|
||
| `trial` (expired) | Redirect to `/billing/subscribe` (except `/billing/` and `/settings/`) |
|
||
| `past_due` | Allow; set `g.billing_warning = 'past_due'` |
|
||
| `cancelled` | Redirect to `/billing/suspended` |
|
||
|
||
### Trial period
|
||
|
||
- New tenants provisioned via `create_tenant()` with `trial_days > 0` get `subscription_status='trial'`, `trial_ends_at = now + N days`. With `trial_days=0` they are provisioned `subscription_status='active'` (no trial) — used by the Free plan (rule 86).
|
||
- Self-service signup (`/signup`) provisions paid plans with a 14-day trial and the **Free plan as active/free-forever**, then sends a `welcome` email (wording adapts per plan).
|
||
- Trial enforcement is in `_billing_gate()` — no Stripe required until they subscribe; `active` and `None` pass through indefinitely.
|
||
|
||
### Billing emails (`app/billing/emails.py`)
|
||
|
||
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text in a background thread.
|
||
|
||
**Sender derivation:** Before launching the thread, `urlparse(app.config['APP_BASE_URL']).netloc` is extracted and the From address is set to `noreply@<netloc>`. This mirrors rule 64 (invitation emails) so billing emails carry the correct tenant domain in the From header rather than a hardcoded address. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is unset or unparseable (`sender=None` passes `None` to `Message()`, triggering Flask-Mail's default).
|
||
|
||
| `event_type` | Trigger | Required context keys |
|
||
|---|---|---|
|
||
| `welcome` | `POST /signup` success | `tenant_name`, `login_url`, `trial_days`, `trial_ends_at`, `plan_name` |
|
||
| `payment_failed` | `invoice.payment_failed` Stripe webhook (day 0) | `portal_url` |
|
||
| `payment_reminder` | Dunning cron (day 3 + day 7) | `portal_url`, `tenant_name`, `days_overdue` |
|
||
| `payment_final` | Dunning cron (day 14) | `portal_url`, `tenant_name`, `days_overdue` |
|
||
| `trial_ending` | Cron `/notifications/trial-reminders` | `trial_ends_at`, `subscribe_url`, `days_left`, `tenant_name` |
|
||
| `subscription_cancelled` | `customer.subscription.deleted` Stripe webhook | `portal_url` |
|
||
|
||
HTML templates live in `app/templates/billing/email/`. `base.html` provides the branded layout; each event type extends it.
|
||
|
||
### Dunning sequence
|
||
|
||
Three `Tenant` columns track payment-failure escalation state:
|
||
|
||
| Column | Type | Purpose |
|
||
|---|---|---|
|
||
| `past_due_since` | `DATETIME NULL` | Set when payment first fails (webhook `_on_payment_failed`). Reset to `NULL` on recovery. |
|
||
| `dunning_stage` | `TINYINT DEFAULT 0` | 0=none, 1=day-3 sent, 2=day-7 sent, 3=day-14 sent. Reset to 0 on recovery. |
|
||
| `dunning_sent_at` | `DATETIME NULL` | Timestamp of the last dunning email. Reset to `NULL` on recovery. |
|
||
|
||
Cron endpoint `POST /notifications/dunning-reminders` runs daily, checks `elapsed = now - past_due_since`, and advances the stage if behind schedule. Migration: `control0004_dunning_tracking`.
|
||
|
||
### Plan seeding
|
||
|
||
```bash
|
||
# Add to .env:
|
||
STRIPE_PRICE_STARTER=price_...
|
||
STRIPE_PRICE_PRO=price_...
|
||
STRIPE_PRICE_ENTERPRISE=price_...
|
||
|
||
# Then run:
|
||
python -m control.cli seed
|
||
python -m control.cli list-plans # verify price IDs
|
||
```
|
||
|
||
### Superadmin billing controls (panel `POST /tenants/<id>/billing`)
|
||
|
||
| Action | Form field | Effect |
|
||
|---|---|---|
|
||
| `set_trial` | `trial_days` (int) | Sets `subscription_status=trial`, `trial_ends_at=now+N` |
|
||
| `set_status` | `subscription_status` | Overrides status directly |
|
||
| `apply_coupon` | `coupon_id` | Calls `stripe.Customer.modify(..., coupon=id)` |
|
||
|
||
All three actions write a `TenantAudit` row.
|
||
|
||
### Invoice history
|
||
|
||
`GET /settings/plan` fetches last 10 Stripe invoices for the tenant's `stripe_customer_id` and passes them to `plan.html`. Shows date, amount, status badge, PDF link, hosted invoice link.
|
||
|
||
### Self-service signup (`/signup`)
|
||
|
||
Public route, exempt from tenant middleware. Reached from the public landing page (`landing.index`) CTA buttons. `SignupForm` validates:
|
||
- Company name, full name, email
|
||
- Subdomain: DNS label regex + uniqueness check against control DB
|
||
- Plan picker (Free / Starter / Pro / Enterprise — choices loaded from the control DB, cheapest-first so **Free is the default**)
|
||
- Password + confirm
|
||
|
||
On submit calls `create_tenant(admin_password=pw, trial_days=0 if free else 14)` (rule 86). Shows `signup/success.html` with workspace URL and (for paid plans) trial end date.
|
||
|
||
---
|
||
|
||
## 24. Backup CLI (`control/backup.py`)
|
||
|
||
Per-tenant MySQL backup using `mysqldump`.
|
||
|
||
```bash
|
||
# Back up all tenants
|
||
python -m control.backup --tenant all --output-dir /var/backups/jqc
|
||
|
||
# Back up one tenant
|
||
python -m control.backup --tenant acme --output-dir /var/backups/jqc
|
||
|
||
# List tenants
|
||
python -m control.backup --list
|
||
```
|
||
|
||
Output: `<slug>_YYYYMMDD_HHMMSS.sql.gz` (gzip-compressed SQL dump).
|
||
|
||
Flags passed to `mysqldump`: `--single-transaction`, `--routines`, `--triggers`, `--set-gtid-purged=OFF`.
|
||
|
||
**Cron for nightly backup:**
|
||
```bash
|
||
0 2 * * * set -a; . /etc/jqc/control.env; set +a; \
|
||
python -m control.backup --tenant all --output-dir /var/backups/jqc
|
||
```
|
||
|
||
---
|
||
|
||
## 25. Health Dashboard (`/health/` on panel)
|
||
|
||
New nav item in the superadmin panel sidebar. Reads control DB only — no per-tenant DB queries.
|
||
|
||
Shows:
|
||
- Summary cards: total tenants, active, suspended, schema behind
|
||
- Subscription breakdown (count per status)
|
||
- Trial alerts: expired trials, trials expiring within 3 days
|
||
- Full tenant table: slug, name, status, plan, subscription status, trial expiry countdown, Stripe presence, schema freshness, created date
|
||
|
||
Row highlights: yellow = suspended, red = trial expired.
|
||
|
||
---
|
||
|
||
## 26. Coding Rules for AI Assistants
|
||
|
||
These rules apply to every change made to this codebase, without exception.
|
||
|
||
### Before Writing Any Code
|
||
|
||
**Rule 1 — Read the actual file on disk first.**
|
||
Use the view or read tool on every file that will be modified. Never rely on output from a previous turn — a prior edit invalidates earlier view output. Always re-read before a second edit to the same file.
|
||
|
||
**Rule 2 — Trace the full request path.**
|
||
For any bug, follow the request from the browser through: decorator → form validation → route logic → DB write → template render. Identify the exact layer where the failure occurs before proposing a fix.
|
||
|
||
**Rule 3 — Find the root cause. No assumptions.**
|
||
"It might be X" is not sufficient. Confirm X by reading the relevant code. State the root cause explicitly in the response.
|
||
|
||
**Rule 4 — Check both layers of enforcement.**
|
||
Feature gates and quota checks must be applied in BOTH web routes AND `/api/v1` API endpoints. A web-only check is bypassable by the iPad app.
|
||
|
||
### Making Changes
|
||
|
||
**Rule 5 — Make the smallest possible change.**
|
||
Do not rewrite surrounding code. Do not rename variables, restructure functions, or reformat blocks unless explicitly requested.
|
||
|
||
**Rule 6 — Never remove functionality** that was not explicitly asked to be removed.
|
||
|
||
**Rule 7 — Preserve all route names, function names, and variable names** unless explicitly directed otherwise.
|
||
|
||
**Rule 8 — Keep all log handlers.**
|
||
|
||
**Rule 9 — One atomic commit per logical transaction.**
|
||
|
||
### Before Providing the Output
|
||
|
||
**Rule 10 — Verify the fix in the file on disk.**
|
||
After applying a patch, read the changed section back and confirm the intended change is present, no surrounding code was accidentally removed, and import statements are consistent.
|
||
|
||
**Rule 11 — Check for introduced bugs.**
|
||
Ask: Does this change break any other code path that uses the modified function, field, or query?
|
||
|
||
**Rule 12 — State the root cause explicitly** in the deployment instructions. "Changed X to Y" is not enough — explain *why* the old code failed and *how* the fix resolves it.
|
||
|
||
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
|
||
|
||
**Rule 14 — Migrations are required for any schema change.**
|
||
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase38_facility_qr`). **Revision ids must be ≤ 32 characters** — `alembic_version.version_num` is `VARCHAR(32)`; a longer id passes every migration step and then fails the final version-pointer UPDATE with MySQL error 1406 (`Data too long for column 'version_num'`), leaving the DDL applied (auto-committed) but the version stamp still on the previous revision. Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
||
|
||
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||
|
||
| Module | Purpose |
|
||
|---|---|
|
||
| `base.py` | `ControlBase`, `control_session()` context manager, engine from `CONTROL_DATABASE_URL` |
|
||
| `models.py` | `Plan`, `PlanFeature`, `Tenant`, `TenantDomain`, `Superadmin`, `ProvisioningJob`, `TenantAudit` |
|
||
| `crypto.py` | Fernet encrypt/decrypt for `tenants.db_password_enc` |
|
||
| `seed.py` | Idempotent plan seeder (Free / Starter / Pro / Enterprise) |
|
||
| `cli.py` | `seed`, `create-superadmin`, `list-plans` |
|
||
| `tenant_migrate.py` | `bootstrap_tenant()`, `upgrade_tenant()`, `chain_head()`, `current_revision()` + CLI |
|
||
| `provision.py` | `create_tenant()`, `register_tenant_zero()`, `delete_tenant()` + CLI |
|
||
|
||
### Data-plane changes (`app/`)
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `app/__init__.py` | `db = SQLAlchemy(session_options={'class_': RoutingSession})` + `init_tenancy(app)` |
|
||
| `config.py` | `MULTI_TENANT_ENABLED` (default `false`) + pool tunables |
|
||
| `app/tenancy/` | New package — see §3 repo layout |
|
||
|
||
### Plan tiers
|
||
|
||
| Axis | Free | Starter | Pro | Enterprise |
|
||
|---|---|---|---|---|
|
||
| Max users | 3 | 15 | 50 | unlimited |
|
||
| Max facilities | 2 | 10 | 50 | unlimited |
|
||
| Inspections / month | 50 | 500 | 5 000 | unlimited |
|
||
| Issues / month | 50 | 500 | 5 000 | unlimited |
|
||
| Mobile API | ✗ | ✓ | ✓ | ✓ |
|
||
| Scheduled reports | ✗ | ✗ | ✓ | ✓ |
|
||
| Branding | ✗ | ✗ | ✓ | ✓ |
|
||
| Custom domain | ✗ | ✗ | ✓ | ✓ |
|
||
| Subdomain | ✓ | ✓ | ✓ | ✓ |
|
||
|
||
Quota-exceed behaviour: **soft warn** — allow submit, flag for upgrade, never reject.
|
||
|
||
### Provisioner MySQL account (required grants)
|
||
|
||
```sql
|
||
GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION;
|
||
GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost';
|
||
FLUSH PRIVILEGES;
|
||
```
|
||
|
||
### CLI quick-reference
|
||
|
||
```bash
|
||
# Control schema + plans + first superadmin (run once)
|
||
alembic -c control/migrations/alembic.ini upgrade head
|
||
python -m control.cli seed
|
||
python -m control.cli create-superadmin --username admin --email you@example.com
|
||
|
||
# Adopt existing LT database as tenant-zero (run once, no data move)
|
||
python -m control.provision register-tenant-zero \
|
||
--slug lts --name "LT Services" --plan enterprise \
|
||
--db-host 127.0.0.1 --db-name <LT_DB> --db-user <LT_USER> --db-password '<pw>' \
|
||
--custom-domain jqc.ltservicesinc.com --base-domain jqc.app
|
||
|
||
# Provision a new tenant (creates MySQL DB + user + schema + first admin)
|
||
python -m control.provision create-tenant \
|
||
--slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com
|
||
|
||
# Delete / deregister a tenant
|
||
python -m control.provision delete-tenant --slug ztest --drop-db --yes # provisioned DB
|
||
python -m control.provision delete-tenant --slug lts --yes # adopted DB — no --drop-db
|
||
|
||
# Migration status + upgrades
|
||
python -m control.tenant_migrate heads
|
||
python -m control.tenant_migrate current --tenant all
|
||
python -m control.tenant_migrate upgrade --tenant all # incremental (phase33+)
|
||
python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
|
||
```
|
||
|
||
### Enable multi-tenancy (cutover sequence)
|
||
|
||
```bash
|
||
# 1. Register LT as tenant-zero (see above)
|
||
# 2. Smoke-test routing (flag still off)
|
||
curl -sI -H "Host: lts.jqc.app" http://127.0.0.1:8000/ | head -2
|
||
curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2
|
||
# 3. Wildcard DNS: *.jqc.app A <SERVER_IP>
|
||
# 4. Nginx wildcard server block (see §19)
|
||
# 5. Wildcard TLS cert (DNS-01, certbot)
|
||
# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true
|
||
# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc
|
||
``` |