# 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:** May 2026 (Phase B complete — iPad offline inspection app; Critical/Notable hardening pass) --- ## Table of Contents 1. [Project Overview](#1-project-overview) 2. [Tech Stack](#2-tech-stack) 3. [Repository Layout](#3-repository-layout) 4. [Environment & Configuration](#4-environment--configuration) 5. [Database Models](#5-database-models) 6. [Role & Permission Matrix](#6-role--permission-matrix) 7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory) 8. [Utility Modules](#8-utility-modules) 9. [Mobile API (Phase 7 / Phase A / Phase B)](#9-mobile-api-phase-7--phase-a--phase-b) 10. [iPad Native App](#10-ipad-native-app) 11. [Notification System](#11-notification-system) 12. [SLA Engine](#12-sla-engine) 13. [Audit Trail](#13-audit-trail) 14. [PDF Export](#14-pdf-export) 15. [Scheduled Reports](#15-scheduled-reports) 16. [Rate Limiting](#16-rate-limiting) 17. [Alembic Migration Chain](#17-alembic-migration-chain) 18. [Frontend Conventions](#18-frontend-conventions) 19. [Infrastructure](#19-infrastructure) 20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules) 21. [Change Philosophy](#21-change-philosophy) --- ## 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 scorecards and scheduled email digests - **Audit trail** — immutable log of every create/update/delete action - **Mobile API** — JWT-authenticated REST layer for the iPad native app - **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B 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 15+** | | **iPad networking** | **URLSession async/await + NWPathMonitor** | | **iPad auth storage** | **iOS Keychain (Security.framework)** | | Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) | --- ## 3. Repository Layout ``` lt_janitorial_quality_control/ ├── app/ │ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager │ ├── api/ # Mobile REST API │ │ ├── __init__.py # api_bp parent blueprint + register_api() │ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/* │ │ ├── facilities.py # /api/v1/facilities/* (Phase A) │ │ ├── templates.py # /api/v1/templates/* (Phase A) │ │ ├── inspections.py # /api/v1/inspections/* (Phase B) │ │ ├── issues.py # /api/v1/issues/* (Phase B) │ │ ├── photos.py # /api/v1/photos/upload (Phase B) │ │ ├── decorators.py # @jwt_required │ │ ├── errors.py # JSON error helpers + error handler registration │ │ └── jwt_utils.py # generate_access_token() │ ├── models/ │ │ ├── inspection.py # Inspection now has mobile_local_id column (Phase B) │ │ ├── issue.py # Issue now has mobile_local_id column (Phase B) │ │ └── ... # (all other models unchanged) │ ├── routes/ # (unchanged from Phase 12) │ ├── static/ │ ├── templates/ │ └── utils/ ├── migrations/ │ └── versions/ │ ├── phase1_projects_roles.py │ ├── phase6_features.py │ ├── phase7_mobile_api.py │ ├── phase8_notification_matrix.py │ ├── phase9_user_full_name.py │ ├── phase10_customer_password_setup.py │ ├── phase11_director_role.py │ ├── phase12_performance_indexes.py │ └── phase_b_mobile_local_id.py ← HEAD ├── JanitorialQC/ # Xcode iOS project root │ ├── JanitorialQC.xcodeproj │ └── JanitorialQC/ │ ├── JQCApp.swift # @main — SwiftData container, env objects │ ├── ContentView.swift # Auth gate: LoginView ↔ DashboardView │ ├── Auth/ │ │ ├── AuthManager.swift # Login/logout/restore session, Keychain persistence │ │ └── KeychainHelper.swift # Security.framework wrapper │ ├── API/ │ │ ├── APIClient.swift # URLSession + JWT inject + 401 retry + photo upload │ │ └── APIModels.swift # Codable response DTOs │ ├── Sync/ │ │ └── SyncManager.swift # NWPathMonitor + outbox queue processor │ ├── Models/ # SwiftData local models │ │ ├── LocalFacility.swift │ │ ├── LocalArea.swift │ │ ├── LocalTemplate.swift │ │ ├── LocalInspection.swift │ │ ├── LocalIssue.swift │ │ ├── PendingPhoto.swift │ │ └── SyncQueueEntry.swift │ ├── Views/ │ │ ├── Auth/ │ │ │ └── LoginView.swift │ │ ├── Dashboard/ │ │ │ └── DashboardView.swift # Sidebar + all detail views │ │ └── Inspection/ │ │ ├── StartInspectionView.swift │ │ ├── ExecuteInspectionView.swift │ │ ├── FlagIssueView.swift │ │ └── FormRenderer/ │ │ └── FormFieldView.swift # All field type renderers │ └── Utils/ │ └── Constants.swift # baseURL, Keychain key strings ├── config.py ├── gunicorn_config.py ├── requirements.txt ├── run.py └── wsgi.py ``` --- ## 4. Environment & Configuration ### Required Environment Variables | Variable | Notes | |---|---| | `SECRET_KEY` | Flask secret — no fallback; startup fails if absent | | `DATABASE_URL` | e.g. `mysql+pymysql://user:pass@localhost/jqc` | | `MAIL_SERVER` | SMTP hostname | | `MAIL_USERNAME` | SMTP login | | `MAIL_PASSWORD` | SMTP password | | `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects flags | | `APP_BASE_URL` | Full URL for email links | | `MAIL_DEFAULT_SENDER` | From address | | `DIGEST_SECRET` | Authenticates all cron endpoints | | `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. | ### Email SSL Auto-Detection ```python MAIL_USE_SSL = _mail_port == 465 MAIL_USE_TLS = not MAIL_USE_SSL ``` **Critical:** Never set both to `True` — Flask-Mail breaks silently. ### File Uploads - `UPLOAD_FOLDER` = `app/static/uploads/` - `MAX_CONTENT_LENGTH` = 50 MB - Allowed: `png`, `jpg`, `jpeg`, `gif` --- ## 5. Database Models ### User ``` users: id, username (unique, indexed), full_name, email (unique, indexed), password_hash, role (ENUM), created_at, active, password_set, set_password_token (indexed), set_password_token_expires ``` **Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer` **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) 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) ``` ### 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 ``` **`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, severity (low/medium/high/critical), description, photo_path, status (open/in_progress/resolved/pending_verification), assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON), verified_by, verified_at, verification_note, sla_notified, mobile_local_id VARCHAR(64) nullable indexed ← Phase B ``` **`mobile_local_id`:** Same idempotency pattern as `inspections.mobile_local_id`. ### Notification / NotificationPreference ``` notifications: id, user_id, title, body, link, is_read, created_at, issue_id, event_type notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency ``` ### 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) | ✅ | ✅ | ✅ | ✅ | read | | Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | | Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ | | Reports | ✅ | ✅ | ✅ | ✅ | scoped | | Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ | | Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ | | Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ | ### Decorator Map ```python @admin_required # role == 'admin' only @supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes @project_manager_required # role in ('admin', 'director', 'project_manager') @customer_required # role == 'customer' only ``` --- ## 7. Blueprint Prefixes & Route Inventory | Blueprint | Prefix | Notable routes | |---|---|---| | `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` | | `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 | | `templates` | `/templates` | list, create, edit, delete, form editor, preview | | `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, 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 export | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger | | `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//areas` | | `api_templates` | `/api/v1` | `/templates`, `/templates/` | | `api_inspections` | `/api/v1` | `POST /inspections`, `PATCH /inspections/` | | `api_issues` | `/api/v1` | `POST /issues` | | `api_photos` | `/api/v1` | `POST /photos/upload` | --- ## 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()`. ### `scope.py` `get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff. ### `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. ### `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. --- ## 9. Mobile API (Phase 7 / Phase A / Phase B) ### CSRF Exemption Pattern — Critical **`csrf.exempt(api_bp)` does NOT cascade to sub-blueprints.** Flask-WTF's `_is_exempt()` checks the leaf blueprint object. Each child blueprint must be exempted individually in `app/__init__.py`: ```python from app.api import register_api, api_bp from app.api.auth import bp as _api_auth_bp from app.api.facilities import bp as _api_facilities_bp from app.api.templates import bp as _api_templates_bp from app.api.inspections import bp as _api_inspections_bp from app.api.issues import bp as _api_issues_bp from app.api.photos import bp as _api_photos_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) csrf.exempt(_api_inspections_bp) csrf.exempt(_api_issues_bp) csrf.exempt(_api_photos_bp) register_api(app) ``` **Every new Phase C+ blueprint must add its own `csrf.exempt()` line here before `register_api(app)`.** Failing to do so produces a `"The CSRF token is missing."` error on all POST requests to that blueprint. ### 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 ### Rate Limits | Endpoint | Limit | |---|---| | `POST /api/v1/auth/login` | 10/min, 3/sec | | `POST /api/v1/auth/refresh` | 30/min, 5/sec | ### Phase A Endpoints | Endpoint | Auth | Description | |---|---|---| | `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user | | `GET /api/v1/facilities//areas` | jwt_required | Areas for a facility | | `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) | | `GET /api/v1/templates/` | jwt_required | Full template with form_schema | Customer role is blocked from template endpoints (`_ALLOWED_ROLES` check). Facility endpoints honour `get_customer_scope()`. ### Phase B Endpoints | Endpoint | Auth | Description | |---|---|---| | `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` | | `PATCH /api/v1/inspections/` | jwt_required | Update inspection (draft → completed) | | `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id` | | `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` | ### Idempotency Pattern All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt: ```python existing = Model.query.filter_by(mobile_local_id=mobile_local_id).first() if existing: return api_ok({'id': existing.id, 'duplicate': True}) ``` This protects against double-submission when the network fails after the server commits but before the device receives the response. Web-created records have `mobile_local_id = NULL`. ### Photo Upload Flow Photos are uploaded **before** the inspection or issue is submitted: 1. iPad calls `POST /api/v1/photos/upload` with multipart image 2. Server saves to `app/static/uploads/inspection_photos/` or `issue_photos/` 3. Returns `{ "server_path": "uploads/inspection_photos/uuid.jpg" }` 4. iPad includes `server_path` in the subsequent inspection/issue POST ### Score Calculation (Server-Side) `app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly: - Rating value `0` = unanswered → excluded from total - pass_fail accepted values: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` - Returns `float` 0–100 or `None` if no scoreable fields --- ## 10. iPad Native App ### Platform - **Language:** Swift 5.10+ - **UI:** SwiftUI (iPad-only, all four orientations) - **Local DB:** SwiftData (iOS 17+ required) - **Networking:** URLSession async/await - **Connectivity:** NWPathMonitor (Network.framework) - **Token storage:** iOS Keychain (Security.framework) - **Xcode:** 15+ ### Offline-First Architecture The app follows the **outbox pattern** — every inspector action writes to SwiftData first; the server is a secondary destination. ``` Inspector action → SwiftData write (always succeeds) → SyncQueue entry ↓ NWPathMonitor detects reconnect ↓ SyncManager.triggerSync() 1. Upload pending photos 2. Submit completed inspections 3. Submit pending issues 4. Pull fresh reference data ``` ### SwiftData Models | Model | Purpose | |---|---| | `LocalFacility` | Cached facility reference data (read-only on device) | | `LocalArea` | Cached area reference data | | `LocalTemplate` | Cached template + `formSchemaJSON` (raw JSON string) | | `LocalInspection` | Inspector-created inspection records | | `LocalIssue` | Issues flagged during inspections | | `PendingPhoto` | Photos awaiting upload; tracks `localFilePath` → `serverPath` | | `SyncQueueEntry` | Outbox queue (currently unused directly — filtering done in Swift) | ### LocalInspection Status Flow ``` "draft" → "completed" → "synced" → "failed" (after 5 retries) ``` `syncStatus` is separate from `status`: - `status`: inspector workflow state - `syncStatus`: server submission state (`"pending"` | `"synced"` | `"failed"`) ### SyncManager Key Behaviours - **Fetch-then-filter pattern:** All `processPhotoQueue`, `processInspectionQueue`, `processIssueQueue` fetch all records and filter in Swift rather than using `#Predicate` with string literals. This avoids a SwiftData `#Predicate` macro type-inference bug with string comparisons across model boundaries. - **Sequential reference data fetch:** `pullReferenceData()` uses sequential `await` (not `async let`) to avoid Swift 6 actor-isolation warnings on `Decodable` structs. - **Photo-before-inspection ordering:** `processPhotoQueue` runs before `processInspectionQueue`. An inspection is only submitted after all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`. - **Retry limit:** 5 retries per item before marking `syncStatus = "failed"`. ### APIClient Key Behaviours - **401 auto-retry:** On a 401 response, `refreshAccessToken()` is called once and the original request is retried. If refresh fails, `APIError.notAuthenticated` is thrown. - **Keychain token storage:** `kSecAttrAccessibleAfterFirstUnlock` — tokens survive device reboot, accessible for background sync. - **Photo upload:** Multipart `form-data` built manually (no third-party library). Boundary is a UUID string. ### FormFieldView — Supported Field Types All types from the web app's `INPUT_FIELD_TYPES` set are rendered: | Type | SwiftUI renderer | |---|---| | `text`, `email` | `TextField` | | `textarea` | `TextEditor` | | `number` | `TextField` + `.decimalPad` | | `date` | `DatePicker` | | `checkbox` | `Toggle` | | `checkbox_group` | Custom multi-select buttons | | `radio` | Custom radio buttons | | `select` | `Picker(.menu)` | | `rating` | Custom star rating (tap same star to clear) | | `pass_fail` | Two-button Pass/Fail control | | `signature` | `PKCanvasView` (PencilKit) | | `image` | `UIImagePickerController` sheet → local file save | | `table` | `Grid` of `TextField` | | `section`, `label` | Display-only `Text` | ### Known iOS-Specific Constraints | # | Constraint | Rationale | |---|---|---| | 1 | **`import Combine` required for `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors | | 2 | **`NavigationSplitView` — no `selection:` binding** | `init(selection:content:)` unavailable on iPadOS 17; use `List` with manual `Button` + `@State var selectedTab` | | 3 | **`#Predicate` — no string literal comparisons across model boundaries** | SwiftData macro type-inference bug; fetch all + filter in Swift instead | | 4 | **`async let` — Swift 6 actor-isolation warnings on Decodable** | Use sequential `await` calls for reference data fetches | | 5 | **PencilKit requires framework linkage** | Add `PencilKit.framework` under Target → Frameworks, Libraries, and Embedded Content | | 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls | | 7 | **`kSecAttrAccessibleAfterFirstUnlock` for background sync** | Tokens must be readable when the app is woken by BGTaskScheduler | --- ## 11. Notification System ### Event Constants (`app/models/notification.py`) ``` inspection_completed, issue_assigned, issue_reassigned, issue_unassigned, issue_status, issue_comment, issue_follow_update, issue_flagged, issue_created, issue_updated_customer, verification_requested, sla_alert, customer_inspection_completed ``` ### 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 * * *` | --- ## 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=` --- ## 16. Rate Limiting ```python limiter = Limiter( key_func = get_remote_address, default_limits = [], storage_uri = os.environ.get('REDIS_URL', 'memory://'), ) ``` **Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`. Counters shared across all Gunicorn workers. --- ## 17. Alembic Migration Chain ``` 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 ← HEAD ``` ### phase_b_mobile_local_id Adds `mobile_local_id VARCHAR(64) NULL` + index to both `inspections` and `issues`. Uses `INFORMATION_SCHEMA.COLUMNS` and `INFORMATION_SCHEMA.STATISTICS` existence checks — safe to re-run. ### phase13_issue_facility Adds nullable `facility_id` FK column to `issues`; back-fills from `areas.facility_id`; makes `area_id` nullable. Uses direct `ALTER TABLE` + `INFORMATION_SCHEMA` checks — safe to re-run. ### phase14_facility_created_at Adds nullable `created_at` `DATETIME` column to `facilities`. Uses direct `ALTER TABLE` + `INFORMATION_SCHEMA` check — safe to re-run. ### phase15_audit_log_indexes Adds individual indexes on `audit_logs.action` and `audit_logs.entity_type`. Uses `INFORMATION_SCHEMA.STATISTICS` existence checks — safe to re-run. ### MySQL ENUM Change Protocol (3 steps — always follow) ```sql -- 1. Expand ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL; -- 2. Migrate UPDATE users SET role = 'director' WHERE role = 'supervisor'; -- 3. Contract ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL; ``` ### MySQL Compatibility Rules - **`CREATE INDEX IF NOT EXISTS`** — not supported on MySQL < 8.0.12. Always use `INFORMATION_SCHEMA.STATISTICS` check first. - **`batch_alter_table`** — SQLite-only workaround; do not use for MySQL migrations. Use direct `ALTER TABLE` statements. - **Migration deploy order:** Always run `flask db upgrade` with the **old** `app/__init__.py` still in place if the new version imports models that reference columns the migration would add. Swap `__init__.py` after the migration succeeds. ### Deprecated SQLAlchemy Patterns ```python # WRONG Model.query.get(id) # CORRECT obj = db.session.get(Model, id) if obj is None: abort(404) ``` --- ## 18. Frontend Conventions ### Active Nav Tab Detected via `request.endpoint.startswith('.')` in each nav `` tag. ### Display Names Always use `user.display_name` in templates — never `.username` for display purposes. ### Status Label Map | DB value | Displayed as | |---|---| | `completed` | **Submitted** | | `in_progress` | In Progress | | `flagged` | Flagged | | `open` | Open | | `resolved` | Resolved | | `pending_verification` | Pending Verification | ### Forms - Flask-WTF CSRF auto-applied to all web forms - **Never nest `
` tags** — browsers silently discard inner forms ### Real-Time **SSE banned.** All "live" updates use polling. --- ## 19. Infrastructure ### Gunicorn ```python bind = "127.0.0.1:8000" workers = multiprocessing.cpu_count() * 2 + 1 worker_class = "sync" timeout = 30 ``` ### Application Logging - `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB) - `StreamHandler` → stdout (journalctl) - Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message` ### Nginx - `client_max_body_size 50M` - Passes `X-Forwarded-For` ### Recommended Cron Schedule ```bash 0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \ -d "token=SECRET&frequency=daily" */30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \ -d "token=SECRET" 0 3 * * * curl -s -X POST https://your-domain.com/notifications/cleanup-tokens \ -d "token=SECRET" 0 8 * * * curl -s -X POST https://your-domain.com/scheduled-reports/run \ -d "secret=SECRET" ``` --- ## 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 `` 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_next()` in auth.py; `_safe_referrer()` in customers.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 | **`import Combine` required in iOS files using `@Published`** | Swift 5.9+ does not auto-import Combine | | 25 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 | | 26 | **SwiftData `#Predicate` — fetch all + filter in Swift for string comparisons** | Macro type-inference bug with string literals across model type boundaries | | 27 | **Sequential `await` for reference data fetches in SyncManager** | `async let` causes Swift 6 actor-isolation warnings on Decodable structs | | 28 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks | | 29 | **`get_customer_scope()` uses bulk project query** | Replaces per-assignment loop | | 30 | **CSV exports always call `log_action(ACTION_EXPORT, ...)`** | Data exports are compliance-relevant audit events | | 31 | **Do NOT add an explicit `Issue.area` relationship** | `Area.issues` declares `backref='area'`, supplying `Issue.area` automatically. A second declaration on `Issue` raises `ConflictingBackreferences` at startup. The dependency is documented here; do not "fix" it by adding an explicit relationship. | | 32 | **Do not sync an issue when its parent `LocalInspection.syncStatus == "failed"`** | Submitting without `inspection_id` creates orphaned server records; mark issue `"failed"` instead | | 33 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes; use `"\u2014"` not `'—'` inside `f'...'` | --- ## 21. Change Philosophy 1. **Surgical, additive patches** — smallest possible change to achieve the goal 2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise 3. **Never remove existing functionality** unless explicitly directed 4. **Log all create/update/delete actions** via `log_action()` 5. **Migration existence checks** — all migrations safe to re-run 6. **Full file contents for 1–3 file changes**; deployment map for larger changesets 7. **Explicit deploy instructions** — migration steps separated from code steps 8. **Root cause analysis** on errors — never apply temporary workarounds