Files
LT_Janitorial_Quality_Control/CLAUDE.md
T
2026-05-16 14:53:21 -04:00

845 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 18 complete — reported_by on issues, issue_flagged notification prefs, dashboard follow-up query fix, customer join refactor)
---
## 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 + 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 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, facility_id (nullable), severity (low/medium/high/critical),
description, photo_path, 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),
verified_by, verified_at, verification_note, sla_notified,
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
```
**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue — on the web (`current_user.id`) and via the mobile API (`g.api_user.id`). Nullable for backward compatibility; pre-phase18 rows have `NULL`. 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
```
**`event_type`:** Added in phase17. Stored by `notify()` and returned by `GET /api/v1/notifications` so the iPad can categorise alerts. `NULL` for notifications created before the migration.
### 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/<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` |
| `api_photos` | `/api/v1` | `POST /photos/upload` |
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
---
## 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 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. `notify()` stores `event_type` on the `Notification` record (phase17+) so the mobile API can return it to the iPad for categorisation. `flag_followup` route calls `notify()` for the original inspector so they receive a follow-up request notification on the iPad.
### `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 / Phase C)
### 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
from app.api.notifications import bp as _api_notifications_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)
csrf.exempt(_api_notifications_bp)
register_api(app)
```
**Every new Phase D+ 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/<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 |
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/<id>` | 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` |
### 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 current user (inspectors); all non-resolved (admin/director/PM) |
| `GET /api/v1/issues/<id>` | jwt_required | Single issue detail — inspectors scoped to assigned only |
| `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status — inspectors scoped to assigned only |
| `GET /api/v1/notifications` | jwt_required | Unread notifications for current user; accepts `?since=<ISO 8601>` |
| `PATCH /api/v1/notifications/mark-read` | jwt_required | Mark list of notification IDs as read |
### Issue API Scope Rules
- **Inspector:** `GET /issues` returns issues where `assigned_to == current_user.id` **OR** `reported_by == current_user.id`. This ensures issues the inspector created on the iPad appear even before a director assigns them. `GET /issues/<id>` and `PATCH /issues/<id>/status` enforce the same combined check.
- **Admin / Director / Project Manager:** `GET /issues` returns all non-resolved issues (default) or filtered by `?status=`.
- `_issue_payload()` returns: `id`, `status`, `severity`, `description`, `assigned_to`, `facility_id`, `facility_name`, `reported_at`, `resolved_at`, `mobile_local_id`, `photo_path`, `result_photos`.
### Notification API — OperationalError Safety
`GET /api/v1/notifications` wraps the ORM query in `try/except sqlalchemy.exc.OperationalError`. If the `event_type` column does not yet exist (phase17 migration not run), it falls back to a raw-SQL query that omits the column and returns `"event_type": null`. This keeps the endpoint functional before and after the migration.
### 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` 0100 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"`.
- **`pullAssignedIssues()`:** Fetches `GET /api/v1/issues` and upserts into SwiftData keyed by `serverId`. Records pulled from server carry `syncStatus = "synced"` and `inspectionLocalId = ""` so `processIssueQueue` never re-submits them. **Deletion pass runs after upsert** — records with `syncStatus == "synced"` AND `inspectionLocalId == ""` whose `serverId` is absent from the server response are deleted. This removes issues that were reassigned to another inspector. The empty-response case is not short-circuited, so unassignment is always handled.
- **Notification polling:** `pollNotifications()` is called at the end of every `triggerSync()` and also on a 60-second `Task.sleep` loop started by `startPollTask()`. Uses `lastNotificationFetch` as a cursor (`?since=` param) so only new notifications are fetched. Marks fetched IDs read on server after local delivery.
- **`Task.sleep` not `Timer.scheduledTimer`:** `Timer.scheduledTimer` requires `RunLoop.main` to be ticking; inside a Swift Concurrency `Task { @MainActor }` block `RunLoop.current` is not `RunLoop.main` and the timer fires never. Always use `Task.sleep` for periodic work in SyncManager.
### 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`)
```
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' ← added; must be in ALL_EVENT_TYPES
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
```
**`ALL_EVENT_TYPES`** is the authoritative dict for the preferences UI. Every `event_type` string passed to `notify()` or `notify_by_matrix()` must have a matching entry here — missing entries cause that event to be invisible in the preferences form.
### 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=<DIGEST_SECRET>`
---
## 16. Rate Limiting
```python
limiter = Limiter(
key_func = get_remote_address,
default_limits = [],
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
)
```
**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`. 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 → phase16_notifications_columns
→ phase17_notification_event_type
→ phase18_issue_reported_by ← 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.
### phase16_notifications_columns
Ensures `digest_pending TINYINT NOT NULL DEFAULT 0` and `inspection_id INT NULL FK` exist on the `notifications` table. Both columns are defined in the model but were absent from any prior migration because the `notifications` table predates the chain. Uses `INFORMATION_SCHEMA` existence checks — safe to re-run.
### phase17_notification_event_type
Adds `event_type VARCHAR(50) NULL` to the `notifications` table. Required for `GET /api/v1/notifications` to return event type to the iPad so it can categorise alerts. The API endpoint catches `OperationalError` and falls back to raw SQL before this migration runs. Uses `INFORMATION_SCHEMA` existence check — safe to re-run.
### phase18_issue_reported_by
Adds `reported_by INT NULL FK → users.id ON DELETE SET NULL` to the `issues` table. Allows the mobile API to return issues the inspector created (but hasn't been assigned) alongside their assigned issues. Nullable — pre-phase18 rows have `NULL` and surface only via the `assigned_to` path. Uses `INFORMATION_SCHEMA` existence and constraint 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('<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.
---
## 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 `<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` — the single canonical utility, imported by both `auth.py` and `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'...'` |
| 34 | **`computeScore` field ID must be cast explicitly: `String``as? String`, `Int``as? Int` then `String(n)`** | `Optional.map` on `Any?` returns `Optional(value)` not `value`; the old guard-let produced `"Optional(5)"` as the lookup key, so all integer-ID field scores were silently 0 |
| 35 | **Strip `local://` photo paths from `formData` before `submitInspection`** | A failed photo upload leaves `"local://..."` in formData; `JSONSerialization` drops non-serialisable values silently, which is worse than an empty string on the server |
| 36 | **`notify()` must always receive `event_type`** | Without it the mobile API returns `null` for event type and the iPad cannot categorise the alert banner |
| 37 | **`flag_followup` calls `notify()` for the original inspector** | Without this, the inspector receives no follow-up request notification on any channel |
| 38 | **`GET /api/v1/notifications` catches `OperationalError`** | If phase17 migration hasn't run, the ORM query fails at the SQL layer because `event_type` is in the SELECT but not in the DB; `getattr()` does NOT protect against this — only a try/except does |
| 39 | **Issue API scope: inspectors see assigned OR reported issues** | `GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status` all enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role. Pre-phase18 rows with `reported_by = NULL` surface only via `assigned_to`. |
| 40 | **`_issue_payload()` must return `photo_path` and `result_photos`** | iPad `pullAssignedIssues` stores these in `photoServerPaths` for display via `AsyncImage`; omitting them means server-created issues show no photos |
| 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row; calling it mid-transaction prematurely commits dirty session state. Snapshot any label strings needed for the audit call before the main commit if they come from ORM objects that may expire. |
| 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; comparing to `None` does not generate a "has no rows" predicate. Use `~.any()` which emits a proper `NOT EXISTS` subquery. |
| 43 | **`issues.index()` outerjoin must precede all filters** | `outerjoin(Area, Issue.area_id == Area.id)` is unconditional at the top of the query. Both the customer-scope block and the `facility_filter` block reference `Area.facility_id`; without a prior join the `facility_filter` path generates a cartesian product for non-customer users. |
---
## 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