Files
JQC_multi_tenant/CLAUDE.md
T
2026-06-28 10:16:20 -04:00

88 KiB
Raw Blame History

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: June 2026 (Phase 19 complete + mobile API gap-fill Phases AE + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + MT-0 through MT-7 complete; MT-8 flag-gated; MT-9 iOS pending)


Table of Contents

  1. Project Overview
  2. Tech Stack
  3. Repository Layout
  4. Environment & Configuration
  5. Database Models
  6. Role & Permission Matrix
  7. Blueprint Prefixes & Route Inventory
  8. Utility Modules
  9. Mobile API (Phase 7 / Phase AE)
  10. iPad Native App
  11. Notification System
  12. SLA Engine
  13. Audit Trail
  14. PDF Export
  15. Scheduled Reports
  16. Rate Limiting
  17. Alembic Migration Chain
  18. Frontend Conventions
  19. Infrastructure
  20. Known Constraints & Hard Rules
  21. Change Philosophy
  22. Multi-Tenant Architecture (MT-0 → MT-7)
  23. Coding Rules for AI Assistants

1. Project Overview

JQC (Janitorial Quality Control) is a production-grade, full-stack web application that manages:

  • Janitorial service contracts organised as Contracts (Projects) → Facilities → Areas
  • Inspection execution against configurable templates with dynamic form builder
  • Issue tracking with SLA enforcement, follower subscriptions, and verification workflow
  • Customer portal with scoped facility visibility and invitation-based onboarding
  • Notification system (in-app + email) driven by an admin-controlled matrix
  • Reports — on-demand PDF/CSV/Excel scorecards, scheduled email digests, Issues Aging, SLA Compliance, Follow-up Closure Rate, and per-facility Customer PDF Summary
  • Audit trail — immutable log of every create/update/delete action
  • Support chat — Groq AI chatbot for customers with preset FAQ chips; escalation to admin via ticketing system; customers can view and reply to their own tickets; admins manage tickets at /support/admin/tickets
  • Mobile API — JWT-authenticated REST layer for the iPad native app
  • iPad native app — SwiftUI + SwiftData offline-first inspection tool (Phase A + B + C complete)

The application is actively deployed in production and maintained by a single developer/administrator.


2. Tech Stack

Layer Technology
Language Python 3.11+
Web framework Flask (application factory pattern)
ORM Flask-SQLAlchemy (SQLAlchemy 2.x)
Database MySQL (via PyMySQL driver)
Auth (web) Flask-Login + Flask-WTF CSRF
Auth (API) JWT access tokens + opaque refresh tokens (PyJWT)
Rate limiting Flask-Limiter (Redis-backed in production via REDIS_URL; falls back to in-process memory for dev)
Migrations Flask-Migrate / Alembic
Email Flask-Mail (SMTP, background threading)
PDF generation ReportLab
Forms WTForms + Flask-WTF
Templating Jinja2
Frontend Bootstrap 5, Chart.js, vanilla JS
Server Gunicorn (sync workers) behind Nginx
OS Ubuntu Linux
iPad app SwiftUI + SwiftData, iOS 17+, Xcode 26
iPad networking URLSession async/await + NWPathMonitor
iPad auth storage iOS Keychain (Security.framework)
Timezone All datetimes stored as US/Eastern (naive, via now_eastern())

3. Repository Layout

lt_janitorial_quality_control/
├── app/
│   ├── __init__.py              # Application factory — limiter, csrf, db (RoutingSession), mail, login_manager, init_tenancy
│   ├── tenancy/                 # MT-1 — tenant resolution + DB routing (inert unless MULTI_TENANT_ENABLED=true)
│   │   ├── __init__.py          # public exports: RoutingSession, init_tenancy, TenantContext, feature_required, quota_soft_check
│   │   ├── context.py           # TenantContext frozen dataclass (g.tenant) — MT-5: +9 plan fields
│   │   ├── engine_cache.py      # per-tenant SQLAlchemy engine cache + invalidate()
│   │   ├── gates.py             # MT-5: @feature_required (hard 403) + @quota_soft_check (soft warn)
│   │   ├── middleware.py        # init_tenancy() — before_request Host→tenant resolver + MT-4 impersonation override
│   │   ├── quota.py             # MT-5: live quota counters (inspections/issues/users/facilities)
│   │   ├── resolver.py          # resolve_tenant(host) → TenantContext | None — MT-5: loads plan fields
│   │   └── routing.py           # RoutingSession — routes db.session to g.tenant_engine
│   ├── api/                     # Mobile REST API
│   │   ├── __init__.py          # api_bp parent blueprint + register_api()
│   │   ├── auth.py              # /api/v1/auth/* and /api/v1/devices/*
│   │   ├── facilities.py        # /api/v1/facilities/* (Phase A)
│   │   ├── templates.py         # /api/v1/templates/* (Phase A)
│   │   ├── inspections.py       # /api/v1/inspections/* (Phase B)
│   │   ├── issues.py            # /api/v1/issues/* (Phase B + Phase 19 + Phase E)
│   │   ├── photos.py            # /api/v1/photos/upload (Phase B)
│   │   ├── stats.py             # /api/v1/stats/dashboard (Phase B stats)
│   │   ├── comments.py          # /api/v1/issues/<id>/comments (Phase D)
│   │   ├── decorators.py        # @jwt_required
│   │   ├── errors.py            # JSON error helpers + error handler registration
│   │   └── jwt_utils.py         # generate_access_token()
│   ├── models/
│   │   ├── inspection.py        # Inspection — mobile_local_id column (Phase B)
│   │   ├── issue.py             # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
│   │   ├── support.py           # 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, domain mgmt (@admin_required)
│   │   └── ...
│   ├── templates/
│   │   ├── _quota_warning.html  # MT-5: reusable quota exceeded banner partial
│   │   ├── tenant_settings/     # MT-7: branding.html, plan.html, domains.html
│   │   └── ...
│   ├── 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
│   ├── 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 routes
│   │   ├── wsgi_panel.py        # WSGI entry point — Gunicorn on port 8001
│   │   └── templates/panel/     # base.html, login.html, tenants_list.html, tenant_detail.html, provision.html
│   └── migrations/              # control Alembic chain (control{N}_…)
│       └── versions/control0001_init.py ← HEAD
└── ...

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,/api/v1/discover). /static/ is always exempt.
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.

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

Role ENUM: admin, director, inspector, project_manager, customer

Key property: display_namefull_name.strip() or falls back to username.

Facility / Area

facilities: id, name, address, contact_person, contact_phone, active, project_id (FK)
areas:      id, facility_id (FK), name, area_type

area_type choices: restroom, lobby, hallway, office, kitchen, storage, floor, outdoor, other

Project / CustomerAssignment

projects: id, name, description, project_manager_id, active, created_at
customer_assignments: id, user_id, project_id, facility_id (nullable)
    UniqueConstraint(user_id, project_id, facility_id)
inspector_assignments: id, user_id, project_id, created_at
    UniqueConstraint(user_id, project_id, name='uq_inspector_project')
    ForeignKey user_id → users(id) ON DELETE CASCADE
    ForeignKey project_id → projects(id) ON DELETE CASCADE

Inspection

inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date,
             overall_score, status (in_progress/completed/flagged), notes, form_data (JSON),
             completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note,
             mobile_local_id VARCHAR(64) nullable indexed   ← Phase B
             submit_latitude DECIMAL(10,7) nullable         ← Phase 25
             submit_longitude DECIMAL(10,7) nullable        ← Phase 25

mobile_local_id: UUID string generated on the iPad. Used for idempotency — if a submission arrives twice (network retry), the server returns the existing record without creating a duplicate. Set NULL for all web-created inspections.

Score rule: Items with score = 0 mean "unanswered" — excluded from calculation entirely.

Issue

issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity (low/medium/high/critical),
        description, photo_path VARCHAR(255), status (open/in_progress/resolved/pending_verification),
        assigned_to, reported_by (nullable FK → users, SET NULL on delete),
        reported_at, resolved_at, result_notes, result_photos (JSON),
        mobile_photo_paths (JSON),                              ← Phase 19
        verified_by, verified_at, verification_note, sla_notified,
        mobile_local_id VARCHAR(64) nullable indexed,           ← Phase B
        vendor_name VARCHAR(100) nullable,                      ← Phase 26
        vendor_contact VARCHAR(200) nullable,                   ← Phase 26
        vendor_notes TEXT nullable                              ← Phase 26

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.

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)

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
    UniqueConstraint(user_id, device_id)

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
dashboard / GET /, /facility-trend (AJAX)
facilities /facilities CRUD + area management
projects /projects CRUD + customer assignment management
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)
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)
support /support GET /chat, POST /chat/message (AJAX→Groq), POST /tickets, GET /my-tickets, GET/POST /my-tickets/<id>, GET /admin/tickets, GET/POST /admin/tickets/<id>
api /api/v1 parent blueprint
api_auth /api/v1 /auth/login, /auth/refresh, /auth/logout, /auth/me, /devices/register
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 GET /discover?subdomain= or ?email=, GET /tenant — MT-9 server side (public, no auth, exempt from tenant middleware)
tenant_settings /settings MT-7: GET/POST /branding, GET /plan, GET /domains, POST /domains/request, POST /domains/<id>/delete

8. Utility Modules

time_utils.py

now_eastern() — always use this, never datetime.utcnow().

audit.py

log_action(action, entity_type, entity_id, entity_label, details) — call after db.session.commit(). This function calls db.session.commit() internally. Calling it before the primary commit will prematurely persist any dirty ORM state in the session.

scope.py

get_customer_scope(user) — returns list[int] facility IDs for customers, None for non-customers. get_inspector_scope(user) — returns list[int] facility IDs for inspectors (empty list = no assignments = no access), None for non-inspectors. Derived from InspectorAssignment rows → project → active facilities.

forms.py

All WTForms classes. AreaForm.area_type includes floor. UserForm excludes customer role.

notifications.py

notify(), notify_by_matrix(), notify_customers_for_facility() — all email sent in background thread. notify() stores event_type on the Notification record (phase17+). flag_followup route calls notify() for the original inspector.

sla.py

sla_status(issue)'ok' | 'at_risk' | 'breached' | None (resolved).

pdf_export.py

ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.

Public functions:

  • generate_inspection_pdf(inspection, form_fields, form_data, issues, static_folder) — per-inspection PDF
  • generate_issues_list_pdf(issues, filter_summary) — landscape issues list PDF (from issues list export)
  • generate_inspections_list_pdf(inspections, filter_summary) — landscape inspections list PDF
  • generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count) — customer-facing one-page facility summary PDF (Phase R4)

_build_styles() registered style names: ReportTitle, ReportSub, SectionHead, FieldLabel, FieldValue, MetaLabel, MetaValue, IssueDesc, FooterStyle, SummaryTitle, ReportSubtitle, Meta, ScoreValue, ScoreLabel, SectionHeader, TableHeader, TableCell

The last eight styles (SummaryTitle through TableCell) were added for the facility summary PDF and are available for any future customer-facing PDF functions.


9. Mobile API (Phase 7 / Phase AE)

CSRF Exemption Pattern — Critical

csrf.exempt(api_bp) does NOT cascade to sub-blueprints. Each child blueprint must be exempted individually in app/__init__.py. The new api_issues blueprint (including its PATCH /issues/<id>/photos route) inherits the exemption already applied to _api_issues_bp. Every new blueprint must add its own csrf.exempt() line before register_api(app).

Auth Flow

  1. POST /api/v1/auth/login → access token (60 min JWT) + refresh token (30 day opaque hex)
  2. Bearer token on every request
  3. POST /api/v1/auth/refresh → token rotation (old revoked, new issued)
  4. POST /api/v1/auth/logout → revokes refresh token

Phase A Endpoints

Endpoint Auth Description
GET /api/v1/facilities jwt_required All active facilities scoped to user
GET /api/v1/facilities/<id>/areas jwt_required Areas for a facility
GET /api/v1/templates jwt_required Template list (summary, no form_schema)
GET /api/v1/templates/<id> jwt_required Full template with form_schema

Phase B Endpoints

Endpoint Auth Description
POST /api/v1/inspections jwt_required Create inspection; idempotent via mobile_local_id
PATCH /api/v1/inspections/<id> jwt_required Update inspection (draft → completed)
POST /api/v1/issues jwt_required Create issue; idempotent via mobile_local_id; accepts result_photos list stored in mobile_photo_paths
POST /api/v1/photos/upload jwt_required Multipart photo upload; returns server_path

Phase C Endpoints

Endpoint Auth Description
GET /api/v1/inspections jwt_required Inspector's own inspection history (paginated)
GET /api/v1/issues jwt_required Issues assigned to OR reported by current user (inspectors); all non-resolved (admin/director/PM)
GET /api/v1/issues/<id> jwt_required Single issue detail
PATCH /api/v1/issues/<id>/status jwt_required Update issue status
GET /api/v1/notifications jwt_required Unread notifications; accepts ?since=<ISO 8601>
PATCH /api/v1/notifications/mark-read jwt_required Mark list of notification IDs as read

Phase 19 Endpoint

Endpoint Auth Description
PATCH /api/v1/issues/<id>/photos jwt_required Attach extra evidence photos to an issue. Accepts { "result_photos": ["uploads/..."] }. Stores in mobile_photo_paths (NOT result_photos). Idempotent — merges with existing paths, never overwrites.

Phase B (Stats) Endpoint

Endpoint Auth Description
GET /api/v1/stats/dashboard jwt_required Inspector-scoped KPIs: today_inspections, completed_today, open_issues, avg_score_30d, pending_followups, sla_breached, sla_at_risk, severity_breakdown (dict: critical/high/medium/low). Inspectors scoped to contracted facilities. Admins/directors/PMs get org-wide numbers. Customers get 403.

Phase D (Comments) Endpoints

Endpoint Auth Description
GET /api/v1/issues/<id>/comments jwt_required All comments oldest-first. Returns: id, issue_id, author_name, author_role, status_at_time, body, created_at. Inspectors limited to contracted facilities.
POST /api/v1/issues/<id>/comments jwt_required Add a comment. Body: { "body": "..." }. Fires notify_by_matrix('issue_comment'). Calls log_action() after commit.

Phase E Additions to Existing Endpoints

_issue_payload() in issues.py now returns area_name and assigned_to_name (both nullable). These populate LocalIssue.areaNameCache and LocalIssue.assignedToName on the iPad after every pullAssignedIssues(). refreshStatusFromServer() also refreshes them on demand.

stats.py now returns severity_breakdown dict alongside the existing KPIs. Derived from the already-loaded open_issues_all list — zero extra DB queries.

Issue API — _issue_payload() fields

{
    'id', 'status', 'severity', 'description', 'assigned_to',
    'facility_id', 'facility_name', 'reported_at', 'resolved_at',
    'mobile_local_id',
    'photo_path',          # primary evidence photo (first iPad photo or web upload)
    'mobile_photo_paths',  # extra evidence photos from iPad (list)
    'result_photos',       # resolution photos added via web form (list)
    # Phase A additions:
    'result_notes',        # resolution notes entered by web staff
    'verified_at',         # ISO 8601 datetime when fix was verified (nullable)
    'verification_note',   # note from the verifier (nullable)
    'reported_by_name',    # display_name of User who filed the issue (nullable)
    # Phase E additions:
    'area_name',           # name of the Area the issue was flagged in (nullable)
    'assigned_to_name',    # display_name of currently assigned User (nullable)
}

iOS reads photo_path + mobile_photo_paths into photoServerPaths. It does NOT read result_photos — those are web-only resolution photos.

Issue API Scope Rules

  • Inspector: GET /issues returns issues where assigned_to == current_user.id OR reported_by == current_user.id.
  • Admin / Director / Project Manager: GET /issues returns all non-resolved issues (default) or filtered by ?status=.
  • GET /issues/<id> and PATCH /issues/<id>/status and PATCH /issues/<id>/photos all enforce the same combined inspector check.

Photo Upload Flow (multi-photo issues)

1. iPad calls POST /api/v1/photos/upload × N  → gets N server_path strings
2. iPad calls POST /api/v1/issues             → sends photo_path = paths[0]
                                                 result_photos = paths[1:]  (stored in mobile_photo_paths)
3. iPad calls PATCH /api/v1/issues/<id>/photos → sends result_photos = paths[1:]
                                                  (PATCH is belt-and-suspenders for race safety)

Web template shows photo_path + mobile_photo_paths together under "Photo Evidence". result_photos (resolution photos from web form) appears under "Resolution Details".

Facility deduplication

pullReferenceData() deduplicates the /api/v1/facilities response by id before upserting. The server may return the same facility ID more than once (one row per contract assignment). Without deduplication, the same building appears twice in every picker. The dedup uses a seenFacilityIds = Set<Int>() filter on the iOS side AND the upsert map (facilityMap) on the server side.

Idempotency Pattern

All Phase B write endpoints accept mobile_local_id (UUID string from device). On receipt, check for existing record and return { 'duplicate': True } without inserting. Web-created records have mobile_local_id = NULL.

Score Calculation (Server-Side)

app/api/inspections.py::_compute_score() mirrors routes/inspections.py::_compute_score_from_form() exactly. Rating value 0 = unanswered → excluded. Returns float 0100 or None if no scoreable fields.


10. iPad Native App

See the iOS app's own CLAUDE.md for full details. Key integration points:

  • App connects to jqc.ltservicesinc.com (primary) or jqc1.ltservicesinc.com (secondary) — server is user-selectable at login and in Settings.
  • Server selection is persisted to UserDefaults via ServerConfig. Switching server in Settings triggers a logout confirmation alert and clears all server-pulled SwiftData records (serverId != nil) before logout.
  • All photo evidence from the iPad routes through mobile_photo_paths on the server — never through result_photos.

11. Notification System

Event Constants (app/models/notification.py)

EVENT_ISSUE_ASSIGNED    = 'issue_assigned'
EVENT_ISSUE_STATUS      = 'issue_status'
EVENT_ISSUE_COMMENT     = 'issue_comment'
EVENT_ISSUE_FOLLOW      = 'issue_follow_update'
EVENT_INSPECTION_DONE   = 'inspection_completed'
EVENT_SLA_ALERT         = 'sla_alert'
EVENT_ISSUE_FLAGGED     = 'issue_flagged'
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
EVENT_CUSTOMER_ISSUE_UPDATED   = 'customer_issue_updated'
EVENT_SCORE_ALERT       = 'score_alert'   ← Phase 27

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 * * *

12. SLA Engine

Severity Window At-Risk
critical 4h 3h
high 24h 18h
medium 72h 54h
low 168h 126h

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: phase33_tenant_settings (31 migrations total).

Chain root: 0003_add_user_active — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. down_revision = None.

0003_add_user_active (baseline, MT-2)
  → phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix
      → phase9_user_full_name → phase10_customer_password_setup → phase11_director_role
          → phase12_performance_indexes → phase_b_mobile_local_id
              → phase13_issue_facility → phase14_facility_created_at
                  → phase15_audit_log_indexes → phase16_notifications_columns
                      → phase17_notification_event_type
                          → phase18_issue_reported_by
                              → phase19_issue_mobile_photos
                                  → phase20_inspector_assignments
                                      → phase21_template_active
                                          → phase21_performance_indexes
                                              → phase22_comment_visibility
                                                  → phase23_support_tickets
                                                      → phase24_notify_defaults
                                                          → phase25_inspection_gps
                                                              → phase26_issue_vendor
                                                                  → phase27_score_alerts
                                                                      → ... → phase32_device_token_columns
                                                                              → phase33_tenant_settings ← HEAD

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 2427:

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 use INFORMATION_SCHEMA.STATISTICS check first.
  • batch_alter_table — SQLite-only workaround; do not use for MySQL migrations.
  • Migration deploy order: Always run flask db upgrade before swapping app/__init__.py if the new version imports models that reference the new columns.

Deprecated SQLAlchemy Patterns

# 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).

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.

Customer role — contract filter scoping: In inspections.index() and issues.index(), the projects list passed to the template is scoped to contracts the customer is assigned to via CustomerAssignment. Non-customer roles still receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.

Customer Dashboard — "Your Facilities" Panel

Rendered in dashboard.html for current_user.role == 'customer'. Uses a Bootstrap card grid instead of a table:

  • Grid: col-12 col-sm-6 col-lg-4 — 3 per row on large, 2 on medium, 1 on small.
  • Collapse (> 9 facilities): First 9 cards are shown; a "Show all N facilities" toggle reveals the rest. Controlled by inline JS (toggleFacilities button, VISIBLE = 9 constant).
  • Live search (> 6 facilities): A #facilitySearch text input filters .facility-col cards in real time by matching against the card's full text content. The show-more bar hides while a search query is active.
  • Count badge: The card header always shows the total facility count as a badge bg-secondary rounded-pill.
  • The JS block is only emitted when customer_facilities|length > 9; the search input is only emitted when customer_facilities|length > 6.

Issue Comments — Visibility & Authorship

  • Comments live in the left column of issues/view.html under the issue description, rendered as chat bubbles.
  • Each bubble shows: colored avatar circle (color keyed to author.id % 7), display name, role badge (Staff / Customer), is_customer_visible badge (staff-only view), status-at-time badge, timestamp, and body.
  • Staff commenting: A hidden checkbox name="is_customer_visible" in the Add Comment form defaults to unchecked (staff-only). Checking it marks the comment visible to customers.
  • Customer commenting: Only shown when can_customer_comment = is_following or issue.reported_by == current_user.id. Customer POST bypasses IssueUpdateForm; the route sets is_customer_visible=True unconditionally.
  • Read filtering: GET issues/view passes filter_by(is_customer_visible=True) to customers; staff receive all comments.

Inspection List Filters

inspections.index() accepts five additional query params: date_from, date_to (ISO date strings), score_min, score_max (0100 floats), inspector_id (int). Inspector filter is suppressed when the viewer has the inspector role (they always see their own only). The inspectors variable is passed to the template only for non-inspector roles so the dropdown is conditionally rendered.

Inspection List — PDF Export & Filter State Preservation

GET /inspections/export-list-pdf — same filter logic as index(), passes current filters as filter_summary string to generate_inspections_list_pdf(). Logs ACTION_EXPORT.

Filter state on back-navigation: list.html adds class insp-list-link to every View/Continue button. On click, JS saves window.location.href to sessionStorage['insp_list_back_url']. view.html reads this key on load and updates the back button href so returning from a detail view restores the previous filter state.

Issues List — ID Filter, Date Filter & PDF Export

issues.index() accepts three additional query params: issue_id (exact match on Issue.id), date_from, date_to (ISO date strings applied to Issue.reported_at). The date_to end is expanded to 23:59:59 so the whole day is included.

GET /issues/export-list-pdf — same scope + filter logic as index(), applies SLA post-filter for ?sla= param (SLA is computed in Python, not stored). Calls generate_issues_list_pdf().

Both index() and export_list_pdf() carry date_from / date_to in pagination links and the unfollow-next URL.

Inspector Performance — Excel Export

GET /reports/export/inspector-performance generates a .xlsx with two sheets:

  • Performance Summary — all inspector KPIs, color-coded cells, totals row
  • Inspection Detail — individual inspection records for the period

Accepts start, end, inspector_id query params. Logs an EXPORT audit action. Uses openpyxl.

Dashboard — Grouped Sections

The dashboard cards are organised into two labelled sections separated by a divider rule:

Inspections section (all roles see first 2; staff see all 4):

  1. Today's Inspections — links to inspections.index filtered by today
  2. Submitted Today — links to inspections.index with status=completed + today's date
  3. Stale In-Progress — inspections with status=in_progress AND inspection_date < now - 24h; links to inspections.index?status=in_progress
  4. Pending Follow-ups — inspections with follow_up_required=True; links to inspections.index?status=follow_up

Issues section (customers see first 3; staff see all 5):

  1. Open Issues — status=open with severity breakdown badges
  2. Issues Opened Today — links to issues.index with date_from=today&date_to=today (uses the date filter added to issues.index)
  3. Resolved Today — status=resolved + today's date range
  4. Pending Verification — status=pending_verification
  5. Unassigned Open — status=open issues with no assigned_to

Each card has a subtitle line explaining what it counts. Section dividers use d-flex align-items-center gap-2 with a <div style="flex:1;height:1px;background:#e2e8f0;"> rule.

Inspector Activity table follows the cards for admin/director/PM: all active inspectors, today's completed inspection count per inspector, progress bar scaled to max_count. Green row highlight if count > 0.

Reports — Navigation & New Pages

The Reports main-nav item is positioned second (right after Dashboard). Scheduled Reports was removed from the main nav and is now a sub-nav tab inside Reports (visible to admin/director/PM).

All report pages include {% include 'reports/_subnav.html' %} as the first element inside {% block content %}. The sub-nav tab visibility is role-gated:

Tab Roles
Overview & Trends All
Issues Aging All
SLA Compliance All
Follow-up Closure admin, director, project_manager
Inspector Performance admin, director
Scheduled Reports admin, director, project_manager

Reports — Phase R1: Issues Aging (/reports/issues-aging)

Loads all non-resolved issues scoped by role, groups into five age buckets (<24h, 13 days, 37 days, 14 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).

Excel export: GET /reports/export/issues-aging — one sheet, color-coded severity and SLA columns.

Helper: _load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter) — extracted so both the HTML route and the Excel export share identical query logic.

Reports — Phase R2: SLA Compliance (/reports/sla-compliance)

Loads resolved issues in the date range, computes within_sla() per issue (compares elapsed hours to SLA_HOURS[severity]). Produces:

  • overall_pct — org/scope-wide compliance %
  • by_severity — dict with total, met, pct, sla_hours per severity tier
  • by_facility — list sorted by compliance % descending

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. Renders:

  • Greeting message with current_user.display_name (injected via var userName = {{ current_user.display_name | tojson }} — use tojson not inline interpolation to prevent XSS/quote breaks).
  • FAQ quick-reply chips: text stored in data-faq="..." HTML attribute (HTML-escaped with | e), read in JS via btn.dataset.faq. Never use | tojson in an onclick="" attribute — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the <script> tag.
  • Chat history kept client-side in let history = [], sent with each AJAX POST /support/chat/message. Server caps at last 20 turns.
  • If GROQ_API_KEY is absent, input is disabled and a fallback "Submit to Support" link is shown.
  • "Submit to Support" modal POSTs to POST /support/tickets; subject pre-filled from last user message in history.

Inspection Execute Page — UX Patterns

  • Photo upload-on-select: uploadPhotoField(input) fires immediately on <input type="file"> change. XHR to POST /<id>/upload-photo. On success, the server path is written to <input type="hidden" id="field_<fid>_server_path"> and a <img id="thumb_<fid>"> is shown.
  • Flag-issue as offcanvas: #flagIssuePanel Bootstrap offcanvas contains the flag-issue form. On submit, saveDraft() fires first, then the form is sent via fetch() FormData, then the page reloads. Never navigates away — photos are never lost.
  • Auto-save draft: setInterval(autoSave, 60000) calls the save-draft endpoint every 60 s. #autoSaveStatus in the footer shows the last-saved timestamp.
  • Progress indicator: Counts answered non-zero rating fields vs. total; updates #progressLabel in the footer on every change.
  • Scroll restore: window.scrollY saved to sessionStorage on beforeunload; restored on load.

19. Infrastructure

Gunicorn

bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
timeout = 30

Application Logging

  • RotatingFileHandlerlogs/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
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"

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
2429 (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
3338 (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 AE 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 Added X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, and a Content-Security-Policy (CDN allowlist + unsafe-inline). Uses setdefault so API responses can override if needed.
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() and issues.index() build the projects list differently for customer role: query CustomerAssignment.query.filter_by(user_id=current_user.id) to get assigned project_id values, then filter Project to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in inspections.start().
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 answeredopen 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).

21. Change Philosophy

  1. Surgical, additive patches — smallest possible change to achieve the goal
  2. Preserve all routes, function names, variable names unless explicitly directed otherwise
  3. Never remove existing functionality unless explicitly directed
  4. Log all create/update/delete actions via log_action()
  5. Migration existence checks — all migrations safe to re-run
  6. Full file contents for 13 file changes; deployment map for larger changesets
  7. Explicit deploy instructions — migration steps separated from code steps
  8. Root cause analysis on errors — never apply temporary workarounds

22. Multi-Tenant Architecture (MT-0 → MT-7)

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 7183)

# 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 redirects to lts.jqc.app at the server level
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. 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 (phase33_tenant_settings). 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