154 KiB
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_devicesblueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicateapi_devicesblueprint +DeviceRegistrationmodel, consolidated onDeviceToken). 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
- Project Overview
- Tech Stack
- Repository Layout
- Environment & Configuration
- Database Models
- Role & Permission Matrix
- Blueprint Prefixes & Route Inventory
- Utility Modules
- Mobile API (Phase 7 / Phase A–E)
- iPad Native App
- Notification System
- SLA Engine
- Audit Trail
- PDF Export
- Scheduled Reports
- Rate Limiting
- Alembic Migration Chain
- Frontend Conventions
- Infrastructure
- Known Constraints & Hard Rules
- Change Philosophy
- Multi-Tenant Architecture (MT-0 → MT-8)
- Billing System (MT-8)
- Backup CLI
- Health Dashboard
- Coding Rules for AI Assistants
- Photo Object Storage (R2)
1. Project Overview
JQC (Janitorial Quality Control) is a production-grade, full-stack web application that manages:
- Janitorial service contracts organised as Contracts (Projects) → Facilities → Areas
- Inspection execution against configurable templates with dynamic form builder
- Issue tracking with SLA enforcement, follower subscriptions, and verification workflow
- Customer portal with scoped facility visibility and invitation-based onboarding
- Notification system (in-app + email) driven by an admin-controlled matrix
- Reports — on-demand PDF/CSV/Excel scorecards, scheduled email digests, Issues Aging, SLA Compliance, Follow-up Closure Rate, and per-facility Customer PDF Summary
- Audit trail — immutable log of every create/update/delete action
- Support chat — Groq AI chatbot for customers with preset FAQ chips; escalation to admin via ticketing system; customers can view and reply to their own tickets; admins manage tickets at
/support/admin/tickets - Mobile API — JWT-authenticated REST layer for the iPad native app
- iPad native app — SwiftUI + SwiftData offline-first inspection tool (Phase A + B + C complete)
- 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.appapex serves a marketing/landing page (landing.index, canonical path/welcome) introducing the product and plans, funneling to signup - Self-service signup — public
/signuppage 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 |
| 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
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)
UserNotificationMatrix (phase54)
user_notification_matrix: id, user_id (FK→users CASCADE, indexed),
event_type VARCHAR(50), enabled BOOL
UniqueConstraint(user_id, event_type)
Per-account override of the global matrix, for the two customer-side roles
only. enabled=True = send even if the global column is OFF; enabled=False =
never send even if it is ON; no row = inherit. Setting a row back to
inherit DELETES it, which is what keeps an account that never expressed an
opinion tracking the global matrix. Helpers: overrides_for_user(),
override_for(), overrides_for_event(), set_overrides() (none commit).
See §27b and rules 100–101.
TemplateContract (phase55)
template_contracts: id, template_id (FK→inspection_templates CASCADE, indexed),
project_id (FK→projects CASCADE, indexed), created_at
UniqueConstraint(template_id, project_id)
Restricts a form to specific contracts. No rows means the form is SHARED
(available on every contract) — rule 102. InspectionTemplate helpers:
contract_ids, is_shared, available_for_project(), set_contracts() (does
not commit), and the static available_query(project_id) — the single
definition of "which forms may this contract use".
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
@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 PDFgenerate_issues_list_pdf(issues, filter_summary)— landscape issues list PDF (from issues list export)generate_inspections_list_pdf(inspections, filter_summary)— landscape inspections list PDFgenerate_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
POST /api/v1/auth/login→ access token (60 min JWT) + refresh token (30 day opaque hex)- Bearer token on every request
POST /api/v1/auth/refresh→ token rotation (old revoked, new issued)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
{
'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 /issuesreturns issues whereassigned_to == current_user.idORreported_by == current_user.id. - Admin / Director / Project Manager:
GET /issuesreturns all non-resolved issues (default) or filtered by?status=. GET /issues/<id>andPATCH /issues/<id>/statusandPATCH /issues/<id>/photosall 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) orjqc1.ltservicesinc.com(secondary) — server is user-selectable at login and in Settings. - Server selection is persisted to
UserDefaultsviaServerConfig. 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_pathson the server — never throughresult_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 (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
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: phase56_followup_assignee.
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
→ phase41_auditor_role → phase42_area_qr_token → phase43_schedule_plan_fields
→ phase44_internal_handler → phase45_schedule_frequency_enum
→ phase46_schedule_recurrence → phase47_schedule_end_date
→ phase48_schedule_parent_inspection → phase49_followup_requested_by
→ phase50_sched_acknowledged → phase51_external_inspector
→ phase52_user_ui_theme → phase53_knowledge_sort_order
→ phase54_user_notif_matrix → phase55_template_contracts
→ phase56_followup_assignee ← HEAD
phase54 / phase55 port the ST August-2026 work (ST calls them phase51 /
phase52; the ids differ because MT's chain was already past those numbers —
match by NAME, not number, when comparing the two repos).
phase54 — per-account notification overrides
Creates user_notification_matrix (§5). No backfill, deliberately — an
empty table means every account inherits the global matrix, i.e. exactly
today's routing, so the migration cannot change who gets notified. Backfilling
from the current global columns would freeze every account at today's routing
and silently break later changes to those columns. Table-existence check —
safe to re-run.
phase55 — restrict forms to specific contracts
Creates template_contracts (§5 TemplateContract). No rows for a template
means SHARED, so every pre-existing form stays available everywhere and the
migration cannot change behaviour on deploy. Table-existence check — safe to
re-run.
phase56 — assign a follow-up to another inspector
Adds inspections.follow_up_assigned_to (FK → users.id, ON DELETE SET NULL)
— see §27c. No backfill: NULL means the follow-up belongs to the
inspection's own inspector, which is what every existing row already means, so
deploying cannot change who owns anything.
This is the THIRD FK from inspections to users (inspector_id,
follow_up_requested_by, and now this). Every relationship spanning the two
tables must pin foreign_keys explicitly or the mapper is ambiguous — and it
raises on first ORM use, not at import, so the app starts cleanly and then
every request 500s. Column + constraint checks — safe to re-run.
ST calls this phase53; MT's chain was already past that number. Match by NAME.
Deploy order (tenant DBs):
python -m control.tenant_migrate upgrade --tenant all
sudo systemctl restart gunicorn
phase41 → phase52 are the single-tenant feature-parity track — see
MULTI_TENANT_PLAN.md §12. Two notes on that tail:
phase51_external_inspectorwidens theusers.roleENUM. It must be deployed together with its code: MT testedrole == 'inspector'literally in ~80 places, and widening the ENUM alone drops external inspectors into the unscoped branch, which is a cross-tenant data leak rather than a cosmetic bug. UseUser.INSPECTOR_ROLES/user.is_inspector, never a literal.- ST's
phase50_default_modernis deliberately NOT ported. It overwrites every savedui_themepreference, which in MT would run against every tenant DB. SetDEFAULT_UI_THEME=modernper tenant instead. See MULTI_TENANT_PLAN.md §12.3.
Migration revision IDs must be ≤ 32 characters to fit
alembic_version.version_num VARCHAR(32).
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_idCASCADE,roleuser/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:
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:
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:
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:
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:
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:
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:
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_registrationstable. - phase31 reversed course: added
ios_version+last_seen_atcolumns to the existingapi_device_tokenstable (phase7) and droppeddevice_registrations. ItsADD COLUMN IF NOT EXISTSnever actually executed on the LT box (MySQL recorded the revision without applying the DDL). - phase32 re-applies the
api_device_tokenscolumn adds using properINFORMATION_SCHEMAexistence checks, and again dropsdevice_registrationsif 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:
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:
python -m control.tenant_migrate upgrade --tenant all
Standard single-tenant migration deploy
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:
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:
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:
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:
flask db upgrade # add mobile_photo_paths column
sudo systemctl restart gunicorn
MySQL ENUM Change Protocol (3 steps — always follow)
-- 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 useINFORMATION_SCHEMA.STATISTICScheck first.batch_alter_table— SQLite-only workaround; do not use for MySQL migrations.- Migration deploy order: Always run
flask db upgradebefore swappingapp/__init__.pyif the new version imports models that reference the new columns.
Deprecated SQLAlchemy Patterns
# 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 (
toggleFacilitiesbutton,VISIBLE = 9constant). - Live search (> 6 facilities): A
#facilitySearchtext input filters.facility-colcards 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 whencustomer_facilities|length > 6.
Issue Comments — Visibility & Authorship
- Comments live in the left column of
issues/view.htmlunder 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_visiblebadge (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 bypassesIssueUpdateForm; the route setsis_customer_visible=Trueunconditionally. - Read filtering:
GET issues/viewpassesfilter_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):
- Today's Inspections — links to
inspections.indexfiltered by today - Submitted Today — links to
inspections.indexwithstatus=completed+ today's date - Stale In-Progress — inspections with
status=in_progressANDinspection_date < now - 24h; links toinspections.index?status=in_progress - Pending Follow-ups — inspections with
follow_up_required=True; links toinspections.index?status=follow_up
Issues section (customers see first 3; staff see all 5):
- Open Issues —
status=openwith severity breakdown badges - Issues Opened Today — links to
issues.indexwithdate_from=today&date_to=today(uses the date filter added toissues.index) - Resolved Today —
status=resolved+ today's date range - Pending Verification —
status=pending_verification - Unassigned Open —
status=openissues with noassigned_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 withtotal,met,pct,sla_hoursper severity tierby_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 viavar userName = {{ current_user.display_name | tojson }}— usetojsonnot 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 viabtn.dataset.faq. Hidden when a prior session is loaded. Never use| tojsonin anonclick=""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 ofSupportChatMessage). The JS variablelet session_idis seeded fromchat_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/reusessession_idacross subsequent messages. - If
GROQ_API_KEYis 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 fromlast_user_msgJS 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/chatwith nosession_id).
Inspection Execute Page — UX Patterns
- Photo upload-on-select:
uploadPhotoField(input)fires immediately on<input type="file">change. XHR toPOST /<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:
#flagIssuePanelBootstrap offcanvas contains the flag-issue form. On submit,saveDraft()fires first, then the form is sent viafetch()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.#autoSaveStatusin the footer shows the last-saved timestamp. - Progress indicator: Counts answered non-zero rating fields vs. total; updates
#progressLabelin the footer on every change. - Scroll restore:
window.scrollYsaved tosessionStorageonbeforeunload; restored onload.
19. Infrastructure
Gunicorn
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):
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.
# /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
# systemd unit [Service]
EnvironmentFile=/etc/jqc/control.env
# CLI sessions
set -a; . /etc/jqc/control.env; set +a
Recommended Cron Schedule
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. |
| 99 | User.CUSTOMER_ROLES is for ACCOUNT MANAGEMENT; role == 'customer' is for CAPABILITY — never swap them |
Widening a capability check to CUSTOMER_ROLES hands a third-party Customer Inspector the customer portal (fails OPEN, nothing errors). Narrowing an account-management check back to 'customer' strands every Customer Inspector in a page that no longer lists or edits them. CUSTOMER_ROLES / is_customer_account appear ONLY in: the /customers list query and its guards, the auth.list_users exclusion, the customer-facing support surface (_is_customer_side()), and narrowing uses that WITHHOLD something from an external account (_assignable_staff_for()). |
| 100 | A per-account notification opt-IN must survive a globally-OFF column | notify_by_matrix() skips a role column early when the matrix says off. For the two customer columns that continue must also ask any(overrides.values()), or the override saves, displays as on, and never sends. Equally, notify_customers_for_facility() re-queries recipients from assignment rows, so notify_by_matrix() must hand it allowed_user_ids or the facility-scoped path bypasses every override. Both halves are needed. |
| 101 | Per-account overrides are enforced in notify(), not only notify_by_matrix() |
Follower fan-out and direct assignee notifications call notify() straight. Gating only the matrix left the editor offering rows ("Issue assigned") that read as Off while the notifications kept arriving. Only an explicit False suppresses; the getattr(recipient, 'is_customer_account', False) test is deliberate so an unavailable attribute SENDS rather than silently dropping. |
| 102 | A template with NO template_contracts rows is SHARED, not hidden |
The empty set means "available on every contract" — that is what makes phase55 additive and why it needed no backfill. Reading it the other way hides every pre-existing form from every contract at once. The convention lives in exactly one place, InspectionTemplate.available_query(). A facility with no contract gets shared forms only (fail-closed). |
| 103 | A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 form= attribute |
Wrapping the table nests the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row buttons post nothing, with no console error and no server log. Applies to all four list templates (classic + modern). |
| 104 | Bulk deletes: DB rows first, storage files second | Collect the keys, db.session.delete() every row, commit(), and only then storage.delete(). _collect_inspection_photos() is shared by the single and bulk inspection delete paths so the two cannot drift — a key missed there is an invisible permanent storage leak. |
| 105 | The flag-issue assignee list is contract-scoped, and BOTH call sites must use _assignable_staff_for(); a failed flag-issue POST must return non-2xx |
execute() renders the dropdown, flag_issue() builds the choices that validate the POST — the choices are the security boundary. An org-wide list let anyone assign another client's Customer Inspector, who was then emailed the facility name and issue description. And the offcanvas JS branches on res.ok, so a 200 re-render of an invalid form reads as success: the panel closes, the page reloads, and no issue exists. |
| 106 | Name the Groq model in the chat error log, and keep _DEFAULT_GROQ_MODEL current |
Groq retires models without notice; when the configured one disappears the API 404s and EVERY question returns the generic "problem reaching the AI assistant" reply, with nothing else broken — invisible until a customer complains. The fix needs no deploy, only GROQ_MODEL, which is exactly what the log line must say. |
| 107 | viewer_is_our_staff in issues/view.html is an explicit role ALLOWLIST, and external_inspector is absent on purpose |
not current_user.is_customer_account fails OPEN — a missing attribute yields Jinja Undefined, not Undefined is true, and the internal-process chrome renders for exactly the accounts it must be hidden from. This is not a rule-87 violation: rule 87 governs capability/scoping, where a Customer Inspector must behave like our inspector; this asks "does this person work for us?", the one place the two genuinely differ. |
| 108 | A follow-up has exactly ONE owner: use follow_up_owner (row) / follow_up_owned_by() (query) — never re-derive it |
Assignee when set, original inspector otherwise. The API's two arms must be mutually exclusive (follow_up_assigned_to == me OR assigned_to IS NULL AND inspector_id == me); drop the IS NULL and two people turn up for the same re-inspection. The authorship filter must be DEFERRED when follow_up_required=true is requested, or the rows the assignee needs are hidden before the ownership test runs. |
| 109 | Inspector READ access is facility scope; WRITE access is authorship | index() lists by facility (rule 58), so view()/export_pdf() must too — scoping reads by authorship made the list offer rows that said "Access denied" on click, and locked the follow-up assignee out of the parent inspection. execute, save_draft_ajax, upload_photo_ajax and flag_issue keep the authorship check: readable is not editable. |
| 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
- Surgical, additive patches — smallest possible change to achieve the goal
- Preserve all routes, function names, variable names unless explicitly directed otherwise
- Never remove existing functionality unless explicitly directed
- Log all create/update/delete actions via
log_action() - Migration existence checks — all migrations safe to re-run
- Full file contents for 1–3 file changes; deployment map for larger changesets
- Explicit deploy instructions — migration steps separated from code steps
- 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
@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)
GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION;
GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost';
FLUSH PRIVILEGES;
CLI quick-reference
# 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)
# 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()withtrial_days > 0getsubscription_status='trial',trial_ends_at = now + N days. Withtrial_days=0they are provisionedsubscription_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 awelcomeemail (wording adapts per plan). - Trial enforcement is in
_billing_gate()— no Stripe required until they subscribe;activeandNonepass 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
# 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.
# 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:
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. Web Portal Design (MT-16)
Two designs share one set of page templates.
base.htmlis a one-line dispatcher:{% extends jqc_layout %}. Page templates keep{% extends "base.html" %}and need no edits.layouts/classic.htmlis the original chrome, verbatim.layouts/modern.htmlis the sidebar shell.jqc_layoutcomes frominject_ui_theme()inapp/__init__.py, driven byusers.ui_themewith configDEFAULT_UI_THEMEas the fallback.- Per-page overrides live at
templates/modern/<same path>.htmland are indexed once at boot. Look forUI themes | modern overrides indexed: Nin the log —0on a host that should have them means the directory did not deploy.
Do not move the template swap into the Jinja loader. It lives in
ThemedEnvironment.get_template() so the template cache is keyed on the
rewritten name. A loader-level swap caches under the original name, so a
modern template can be served to a classic user — and in MT, where one worker
serves many tenants, across tenants.
When adding a page: write it once as a normal template. Only add a
modern/ override if the layout genuinely differs; styling alone is handled by
static/css/theme_modern.css, which is scoped to body.jqc-modern.
27. Roles (MT-15)
users.role ENUM: admin, director, inspector, external_inspector,
project_manager, customer, auditor.
Never test role == 'inspector'. external_inspector (customer /
third-party inspectors) has identical capabilities and identical
InspectorAssignment scoping. Use:
user.is_inspector— true for both inspector roles; use for every capability and scoping checkUser.INSPECTOR_ROLES— forUser.role.in_(...)queriesuser.is_external_inspector— only where the two genuinely differ (display)user.role_label/ROLE_LABELS— for any role name shown in the UI
A literal comparison sends external inspectors down the unscoped branch, where
get_inspector_scope() returns None and every downstream query drops its
facility filter. That is a cross-customer leak.
External inspectors are invited, never given a password: password_set=False
plus an emailed 72-hour token, with auth.resend_invite for bounced invitations.
27b. Customer-side roles, per-account notifications, per-contract forms (Aug 2026 ST parity)
Ported from the single-tenant app (its phase51 + phase52 + the August fixes).
Nothing here is tenant-aware in its own right — every table lives in the tenant
DB and every query routes through RoutingSession as usual.
The two customer-side roles
| Stored ENUM value | Display label | Scoped by | Capabilities |
|---|---|---|---|
customer |
Customer Director | CustomerAssignment |
The portal, unchanged — plus planning scheduled inspections at their own facilities |
external_inspector |
Customer Inspector | InspectorAssignment |
Identical to the internal inspector, plus the customer support surface (AI chat + tickets) |
LABEL-only rename — the ENUM values are untouched, so no migration and no
role check moved. User.ROLE_LABELS is the one place the names live.
User.CUSTOMER_ROLES = ('customer', 'external_inspector') and
User.is_customer_account answer an account-management question ("is this
managed under /customers?"). Every capability check — portal gates,
@customer_required, get_customer_scope(), notify_customers_for_facility(),
the customer branch of each app/api/* module — keeps testing
role == 'customer' exactly (rule 99).
Both roles are now created, invited, assigned and switched in Customer
Management (/customers); auth.list_users excludes them and the
/auth/users/... URLs redirect to customers.manage. UserForm no longer
offers external_inspector, and auth.create_user always requires a password
— customer-side accounts are invited (they choose their own username and
password) via customers.create(). POST /customers/<id>/switch-role
(admin only) mirrors contracts across the two scoping tables and revokes the
account's refresh tokens + device rows, because API access differs between them.
UserNotificationMatrix (per-account overrides)
Row enabled=True = send even if the global column is OFF; enabled=False =
never send even if it is ON; no row = inherit. Inherit is the default, so
the table shipped empty and changed routing for nobody, and setting a row back
to inherit DELETES it. Helpers live in
app/models/user_notification_matrix.py; edited on the account's Customer
Management page as a tri-state. Enforced in notify() as well as
notify_by_matrix() — follower fan-out and direct assignee notifications reach
notify() straight, so gating only the matrix left rows that read as Off while
notifications kept arriving. See rules 100 and 101.
TemplateContract (forms per contract)
InspectionTemplate.available_query(project_id) is the single definition of
"which forms may this contract use" — pickers, the POST validation behind them,
the schedule form and GET /api/v1/templates all call it. No rows = shared
(rule 102). Managed in three places: the template list's Edit modal
(POST /templates/<id>/rename, carrying a hidden contracts_present=1
marker), Create Template, and the full form editor. duplicate_template()
copies the restrictions.
Other ported behaviour
- Bulk actions on the issues and inspections lists (
POST /issues/bulk,POST /inspections/bulk) with shared partials intemplates/partials/. Toolbar form sits OUTSIDE the table; row checkboxes join it with the HTML5form=attribute (rule 103). Deletes remove DB rows first, storage keys second (rule 104). - List filter preservation —
current_url()(Jinja global) +return_url(fallback)(utils/decorators) round-trip the full list URL asnext, so an edit or delete returns to the filtered page.safe_redirect_urlstill guards every hop. - Flag-issue assignee scoping —
_assignable_staff_for(inspection, actor)inroutes/inspections.pyis the single source for both the offcanvas dropdown andform.assigned_to.choices(the actual POST validation). A failed flag-issue POST now returns 400, because the offcanvas JS branches onres.ok(rule 105). - Customer Directors plan inspections —
schedule_manager_requiredinroutes/inspection_schedules.py= the manager set plusrole == 'customer'. Because that blueprint builds its form by hand (no WTForms SelectField), narrowing the choice lists is NOT the validation:_scope_errors()re-checks facility, inspector and form-vs-contract on every POST, and_schedule_in_scope()guards edit/delete. Start remains the assignee's. - Support chat serves both customer roles —
_is_customer_side()opens the door, then_support_facilities()branches per role (CustomerAssignment vs InspectorAssignment) and_system_prompt_for()appends_INSPECTOR_ADDENDUMfor a Customer Inspector. The curated knowledge base is now spliced in before theRules:heading (_STYLE_MARKER) — appended after it, the prompt's own "ground answers in everything above" put it out of scope, which is why KB entries looked ignored./support/admin/knowledge/previewshows the exact prompt.GROQ_MODELdefaults to_DEFAULT_GROQ_MODEL(openai/gpt-oss-120b); Groq retires models without notice, and the error handler names the model and says to setGROQ_MODEL(rule 106). COMMENTS_VISIBLE_TO_ALL(config, default true) lifts the phase22 read filter so customers see every comment.is_customer_visibleis still written, so flipping it back restores the old behaviour with nothing to repair.issues/view.htmlgates the internal chrome onviewer_is_our_staff— an explicit allowlist of OUR roles, which fails closed and deliberately excludesexternal_inspector(rule 107).
27c. Follow-up assignment + inspector read access (Aug 2026 ST parity)
Ported from ST (its phase53 + the two fixes shipped beside it).
Assigning a follow-up (phase56)
A follow-up used to belong implicitly to whoever performed the original
inspection. inspections.follow_up_assigned_to lets a director — or a
Customer Director, for their own facilities — hand the re-inspection to
someone else. Inspection.follow_up_owner (assignee or inspector) is the
single definition of ownership, so the web display, the notification and the
mobile API filter cannot disagree.
The assignee takes over: only the owner is notified, and only the owner
sees it. In GET /api/v1/inspections?follow_up_required=true the two arms are
mutually exclusive — without is_(None) on the second arm the original
inspector keeps seeing a follow-up handed to someone else and two people turn
up to do it. The generic "inspectors see only their own inspections" filter is
deferred when follow-ups are requested, because an assigned follow-up lives
on an inspection somebody else performed.
The picker (_followup_assignees_for()) is contract-scoped for the same reason
the flag-issue list is (rule 105), offers only the two INSPECTOR roles, and the
POST re-validates against it. A facility with no contract offers nobody —
fail-closed, the follow-up stays with the original inspector.
MT-only gap closed on the way: MT's GET /api/v1/inspections had no
follow_up_required filter at all, so the iPad's Follow-up Requests screen
received the inspector's entire history. The filter now matches the web's
definition of "follow-up" — flagged, completed, and not yet answered by a
linked re-inspection (~follow_ups.any()).
Inspector READ access follows the list, not authorship
index() scopes an inspector by FACILITY (rule 58), but view() and
export_pdf() scoped by authorship — so the list offered rows that answered
"Access denied" on click, and the follow-up assignee could not open the parent
inspection they had just been asked to re-inspect. Both reads now use
_inspector_may_read() (facility scope). Writes stay owner-only: execute,
save_draft_ajax, upload_photo_ajax and flag_issue keep the authorship
check. reinspect() belongs to the follow-up's owner; the buttons render only
for is_own_inspection or owns_follow_up, so the page never shows a control
that fails on click.
One rule, two expressions, three callers
Ownership has to be stated twice — once for a loaded row, once in SQL — so both
live together in models/inspection.py:
follow_up_owner— the property (assignee, else inspector)follow_up_owned_by(user_id)— the query predicate
Every query that scopes follow-ups calls the predicate: the mobile list filter,
the web dashboard card, and the iPad stats KPI. They each used to write their
own version and three tested AUTHORSHIP, so an assignee saw the work in their
list while both dashboards read 0 — the stats KPI sitting directly above the
Follow-up Requests list it disagreed with. Fixed in ST at the same time.
Pinned by tests/test_followup_ownership.py.
28. 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)
GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION;
GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost';
FLUSH PRIVILEGES;
CLI quick-reference
# 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)
# 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
29. Photo Object Storage (R2)
Goal: photo files live in Cloudflare R2 (S3-compatible, $0 egress), not on
the server's local disk. The DB is not the bottleneck — rows are tiny, PDFs are
streamed via BytesIO and never written to disk. Only photos accumulate, and in
a multi-tenant deployment they accumulate from every tenant onto one volume.
No schema change, ever. The DB stores an unprefixed relative path
(uploads/issue_photos/abc.jpg) and continues to. That string is the storage
key; app/utils/storage.py maps it to a backend.
Key mapping (the rule that matters)
| Layer | Value |
|---|---|
DB (issues.photo_path, result_photos[], mobile_photo_paths[], inspections.form_data, inspection_results.photo_path) |
uploads/issue_photos/abc.jpg |
| Local backend, on disk | app/static/uploads/issue_photos/abc.jpg |
| S3 backend, object key | t<tenant_id>/uploads/issue_photos/abc.jpg |
The t<tenant_id>/ prefix is applied only inside S3Backend._object_key(),
from g.tenant. It never enters the DB, a template, or an API payload — so the
tenant DB stays portable and every caller stays tenant-agnostic. The local
backend deliberately does not prefix: prefixing would relocate every existing
file, and the local layout must remain byte-identical to what predates the seam.
Local mode therefore shares one uploads directory across tenants — an isolation
weakness inherited from before multi-tenancy, and the reason to move to s3.
S3Backend.save() raises when MULTI_TENANT_ENABLED is true and no tenant
is bound, rather than writing an unprefixed key that a second tenant could later
collide with. Reads are more forgiving: read() / exists() / delete() try
the prefixed key and then the bare key, so objects written before the prefix
existed stay reachable. Pinned by tests/test_storage_backend.py.
Config (all env, per deployment)
STORAGE_BACKEND=local|s3 (default local), plus R2_ENDPOINT_URL,
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PRESIGN_TTL
(default 86400), R2_MEDIA_FALLBACK (default false). boto3 is imported lazily,
so local deployments never touch it — but it is in requirements.txt,
because STORAGE_BACKEND=s3 fails at first upload without it.
STORAGE_BACKEND is process-wide, not per-tenant: one bucket, one backend, all
tenants, isolated by prefix. A per-tenant backend would need the resolver to
carry a storage selector and get_backend() to cache per tenant instead of per
app — do not half-build it.
CSP: set_security_headers in app/__init__.py derives the R2 host from
R2_ENDPOINT_URL and appends it to img-src automatically. Presigned images are
blocked by the browser without this. A custom R2 domain must be added too.
Operator scripts
scripts/audit_photos.py— read-only. Per tenant, collects every key its DB references and reconciles against disk. Records the baseline that must still resolve after cutover, and flags any key claimed by more than one tenant.scripts/migrate_photos_to_r2.py— copy-only, idempotent, resumable, MD5+size verified. Uploads each tenant's referenced files tot<id>/…. Exit 0 only when everything verifies. Orphans (referenced by no tenant) are not uploaded — no prefix could legitimately claim them.
Both establish file ownership from tenant DB references, because the shared
local directory carries none. Both need CONTROL_DATABASE_URL +
CONTROL_FERNET_KEY; neither writes to any database.
Rollback is one env var: STORAGE_BACKEND=local + restart. The sync never
deletes local files, so the old tree is intact indefinitely.