05/21 Update Claude.md

This commit is contained in:
Nguyen Ngo
2026-05-21 14:37:26 -04:00
parent 1459b5316b
commit a5fe4cbfa6
+113 -277
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase. > **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. > **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) > **Last reviewed:** May 2026 (Phase 19 complete — server-selectable iPad app, mobile multi-photo evidence, facility deduplication, issue creation from iPad Issues page)
--- ---
@@ -16,7 +16,7 @@
6. [Role & Permission Matrix](#6-role--permission-matrix) 6. [Role & Permission Matrix](#6-role--permission-matrix)
7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory) 7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory)
8. [Utility Modules](#8-utility-modules) 8. [Utility Modules](#8-utility-modules)
9. [Mobile API (Phase 7 / Phase A / Phase B)](#9-mobile-api-phase-7--phase-a--phase-b) 9. [Mobile API (Phase 7 / Phase A / Phase B / Phase C)](#9-mobile-api-phase-7--phase-a--phase-b--phase-c)
10. [iPad Native App](#10-ipad-native-app) 10. [iPad Native App](#10-ipad-native-app)
11. [Notification System](#11-notification-system) 11. [Notification System](#11-notification-system)
12. [SLA Engine](#12-sla-engine) 12. [SLA Engine](#12-sla-engine)
@@ -69,7 +69,7 @@ The application is actively deployed in production and maintained by a single de
| Frontend | Bootstrap 5, Chart.js, vanilla JS | | Frontend | Bootstrap 5, Chart.js, vanilla JS |
| Server | Gunicorn (sync workers) behind Nginx | | Server | Gunicorn (sync workers) behind Nginx |
| OS | Ubuntu Linux | | OS | Ubuntu Linux |
| **iPad app** | **SwiftUI + SwiftData, iOS 17+, Xcode 15+** | | **iPad app** | **SwiftUI + SwiftData, iOS 17+, Xcode 26** |
| **iPad networking** | **URLSession async/await + NWPathMonitor** | | **iPad networking** | **URLSession async/await + NWPathMonitor** |
| **iPad auth storage** | **iOS Keychain (Security.framework)** | | **iPad auth storage** | **iOS Keychain (Security.framework)** |
| Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) | | Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) |
@@ -88,69 +88,30 @@ lt_janitorial_quality_control/
│ │ ├── facilities.py # /api/v1/facilities/* (Phase A) │ │ ├── facilities.py # /api/v1/facilities/* (Phase A)
│ │ ├── templates.py # /api/v1/templates/* (Phase A) │ │ ├── templates.py # /api/v1/templates/* (Phase A)
│ │ ├── inspections.py # /api/v1/inspections/* (Phase B) │ │ ├── inspections.py # /api/v1/inspections/* (Phase B)
│ │ ├── issues.py # /api/v1/issues/* (Phase B) │ │ ├── issues.py # /api/v1/issues/* (Phase B + Phase 19)
│ │ ├── photos.py # /api/v1/photos/upload (Phase B) │ │ ├── photos.py # /api/v1/photos/upload (Phase B)
│ │ ├── decorators.py # @jwt_required │ │ ├── decorators.py # @jwt_required
│ │ ├── errors.py # JSON error helpers + error handler registration │ │ ├── errors.py # JSON error helpers + error handler registration
│ │ └── jwt_utils.py # generate_access_token() │ │ └── jwt_utils.py # generate_access_token()
│ ├── models/ │ ├── models/
│ │ ├── inspection.py # Inspection now has mobile_local_id column (Phase B) │ │ ├── inspection.py # Inspection mobile_local_id column (Phase B)
│ │ ├── issue.py # Issue now has mobile_local_id column (Phase B) │ │ ├── issue.py # Issue mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
│ │ └── ... # (all other models unchanged) │ │ └── ...
│ ├── routes/ # (unchanged from Phase 12) │ ├── routes/
│ ├── static/ │ ├── 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/ │ ├── templates/
│ │ └── issues/
│ │ ├── view.html # Shows photo_path + mobile_photo_paths under "Photo Evidence"
│ │ └── issues_view.html # Same photo evidence logic
│ └── utils/ │ └── utils/
├── migrations/ ├── migrations/
│ └── versions/ │ └── versions/
── phase1_projects_roles.py ── phase19_issue_mobile_photos.py ← HEAD
│ ├── 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
``` ```
--- ---
@@ -237,14 +198,25 @@ inspections: id, template_id, facility_id, area_id, inspector_id, inspection_dat
``` ```
issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity (low/medium/high/critical), 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), description, photo_path VARCHAR(255), status (open/in_progress/resolved/pending_verification),
assigned_to, reported_by (nullable FK → users, SET NULL on delete), assigned_to, reported_by (nullable FK → users, SET NULL on delete),
reported_at, resolved_at, result_notes, result_photos (JSON), reported_at, resolved_at, result_notes, result_photos (JSON),
mobile_photo_paths (JSON), ← Phase 19
verified_by, verified_at, verification_note, sla_notified, verified_by, verified_at, verification_note, sla_notified,
mobile_local_id VARCHAR(64) nullable indexed ← Phase B 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. **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 ### Notification / NotificationPreference
@@ -254,8 +226,6 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency 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 ### NotificationMatrix
``` ```
@@ -333,7 +303,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
| `api_facilities` | `/api/v1` | `/facilities`, `/facilities/<id>/areas` | | `api_facilities` | `/api/v1` | `/facilities`, `/facilities/<id>/areas` |
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` | | `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
| `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/<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_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_photos` | `/api/v1` | `POST /photos/upload` |
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` | | `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
@@ -354,7 +324,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role. All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role.
### `notifications.py` ### `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. `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.py`
`sla_status(issue)``'ok'` | `'at_risk'` | `'breached'` | `None` (resolved). `sla_status(issue)``'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
@@ -368,28 +338,7 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
### CSRF Exemption Pattern — Critical ### 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`: **`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)`.**
```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 ### Auth Flow
1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex) 1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex)
@@ -397,12 +346,6 @@ register_api(app)
3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued) 3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued)
4. `POST /api/v1/auth/logout` → revokes refresh token 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 ### Phase A Endpoints
| Endpoint | Auth | Description | | Endpoint | Auth | Description |
@@ -412,15 +355,13 @@ register_api(app)
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) | | `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) |
| `GET /api/v1/templates/<id>` | jwt_required | Full template with 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 ### Phase B Endpoints
| Endpoint | Auth | Description | | Endpoint | Auth | Description |
|---|---|---| |---|---|---|
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` | | `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) | | `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/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` | | `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` |
### Phase C Endpoints ### Phase C Endpoints
@@ -428,150 +369,72 @@ Customer role is blocked from template endpoints (`_ALLOWED_ROLES` check). Facil
| Endpoint | Auth | Description | | Endpoint | Auth | Description |
|---|---|---| |---|---|---|
| `GET /api/v1/inspections` | jwt_required | Inspector's own inspection history (paginated) | | `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` | 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 — inspectors scoped to assigned only | | `GET /api/v1/issues/<id>` | jwt_required | Single issue detail |
| `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status — inspectors scoped to assigned only | | `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status |
| `GET /api/v1/notifications` | jwt_required | Unread notifications for current user; accepts `?since=<ISO 8601>` | | `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 | | `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. Access: inspector must be `assigned_to` or `reported_by`. |
### Issue API — `_issue_payload()` fields
```python
{
'id', 'status', 'severity', 'description', 'assigned_to',
'facility_id', 'facility_name', 'reported_at', 'resolved_at',
'mobile_local_id',
'photo_path', # primary evidence photo (first iPad photo or web upload)
'mobile_photo_paths', # extra evidence photos from iPad (list) ← Phase 19
'result_photos', # resolution photos added via web form (list)
}
```
**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 ### 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. - **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=`. - **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`. - `GET /issues/<id>` and `PATCH /issues/<id>/status` and `PATCH /issues/<id>/photos` all enforce the same combined inspector check.
### Notification API — OperationalError Safety ### Photo Upload Flow (multi-photo issues)
`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. ```
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 ### Idempotency Pattern
All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt: 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`.
```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) ### Score Calculation (Server-Side)
`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly: `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.
- 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 ## 10. iPad Native App
### Platform See the iOS app's own `CLAUDE.md` for full details. Key integration points:
- **Language:** Swift 5.10+ - App connects to `jqc.ltservicesinc.com` (primary) or `jqc1.ltservicesinc.com` (secondary) — **server is user-selectable at login and in Settings**.
- **UI:** SwiftUI (iPad-only, all four orientations) - 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.
- **Local DB:** SwiftData (iOS 17+ required) - All photo evidence from the iPad routes through `mobile_photo_paths` on the server — never through `result_photos`.
- **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 |
--- ---
@@ -585,13 +448,11 @@ EVENT_ISSUE_COMMENT = 'issue_comment'
EVENT_ISSUE_FOLLOW = 'issue_follow_update' EVENT_ISSUE_FOLLOW = 'issue_follow_update'
EVENT_INSPECTION_DONE = 'inspection_completed' EVENT_INSPECTION_DONE = 'inspection_completed'
EVENT_SLA_ALERT = 'sla_alert' EVENT_SLA_ALERT = 'sla_alert'
EVENT_ISSUE_FLAGGED = 'issue_flagged' ← added; must be in ALL_EVENT_TYPES EVENT_ISSUE_FLAGGED = 'issue_flagged'
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' 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`) ### Cron Endpoints (all require `token=DIGEST_SECRET`)
| Endpoint | Purpose | Schedule | | Endpoint | Purpose | Schedule |
@@ -647,7 +508,7 @@ limiter = Limiter(
) )
``` ```
**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`. Counters shared across all Gunicorn workers. **Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`.
--- ---
@@ -660,40 +521,19 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase13_issue_facility → phase14_facility_created_at → phase13_issue_facility → phase14_facility_created_at
→ phase15_audit_log_indexes → phase16_notifications_columns → phase15_audit_log_indexes → phase16_notifications_columns
→ phase17_notification_event_type → phase17_notification_event_type
→ phase18_issue_reported_by ← HEAD → phase18_issue_reported_by
→ phase19_issue_mobile_photos ← HEAD
``` ```
### phase_b_mobile_local_id ### phase19_issue_mobile_photos
Adds `mobile_local_id VARCHAR(64) NULL` + index to both `inspections` and `issues`. 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.
Uses `INFORMATION_SCHEMA.COLUMNS` and `INFORMATION_SCHEMA.STATISTICS` existence checks — safe to re-run.
### phase13_issue_facility **Deploy order for phase19:**
```bash
Adds nullable `facility_id` FK column to `issues`; back-fills from `areas.facility_id`; makes `area_id` nullable. flask db upgrade # add mobile_photo_paths column
Uses direct `ALTER TABLE` + `INFORMATION_SCHEMA` checks — safe to re-run. sudo systemctl restart gunicorn
```
### 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) ### MySQL ENUM Change Protocol (3 steps — always follow)
```sql ```sql
@@ -708,8 +548,8 @@ ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
### MySQL Compatibility Rules ### MySQL Compatibility Rules
- **`CREATE INDEX IF NOT EXISTS`** — not supported on MySQL < 8.0.12. Always use `INFORMATION_SCHEMA.STATISTICS` check first. - **`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. - **`batch_alter_table`** — SQLite-only workaround; do not use for MySQL migrations.
- **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. - **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 ### Deprecated SQLAlchemy Patterns
```python ```python
@@ -748,6 +588,10 @@ Always use `user.display_name` in templates — never `.username` for display pu
### Real-Time ### Real-Time
**SSE banned.** All "live" updates use polling. **SSE banned.** All "live" updates use polling.
### Issue Photo Evidence Display (view.html / issues_view.html)
Both templates show `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.
--- ---
## 19. Infrastructure ## 19. Infrastructure
@@ -763,7 +607,6 @@ timeout = 30
### Application Logging ### Application Logging
- `RotatingFileHandler``logs/jqc.log` (5 × 5 MB) - `RotatingFileHandler``logs/jqc.log` (5 × 5 MB)
- `StreamHandler` → stdout (journalctl) - `StreamHandler` → stdout (journalctl)
- Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message`
### Nginx ### Nginx
- `client_max_body_size 50M` - `client_max_body_size 50M`
@@ -801,7 +644,7 @@ timeout = 30
| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement | | 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement |
| 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 | | 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 |
| 14 | **Email in background thread** | Never block HTTP response | | 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` | | 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 | | 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 | | 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 | | 18 | **Set `REDIS_URL` in production** | `memory://` is per-process; Gunicorn needs Redis for accurate shared counters |
@@ -810,26 +653,19 @@ timeout = 30
| 21 | **`mobile_local_id` idempotency on all mobile write endpoints** | Network retries must not create duplicate records | | 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 | | 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 | | 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 | | 2429 | *(iOS-specific — see iOS CLAUDE.md)* | |
| 25 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 | | 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. |
| 26 | **SwiftData `#Predicate` — fetch all + filter in Swift for string comparisons** | Macro type-inference bug with string literals across model type boundaries | | 31 | **Do not sync an issue when its parent `LocalInspection.syncStatus == "failed"`** | Submitting without `inspection_id` creates orphaned server records |
| 27 | **Sequential `await` for reference data fetches in SyncManager** | `async let` causes Swift 6 actor-isolation warnings on Decodable structs | | 32 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes |
| 28 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks | | 3338 | *(field ID casting, photo sentinel, notify event_type, follow-up, OperationalError)* | See prior rule entries |
| 29 | **`get_customer_scope()` uses bulk project query** | Replaces per-assignment loop | | 39 | **Issue API scope: inspectors see assigned OR reported issues** | `GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos` all enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role |
| 30 | **CSV exports always call `log_action(ACTION_EXPORT, ...)`** | Data exports are compliance-relevant audit events | | 40 | **`_issue_payload()` must return `photo_path`, `mobile_photo_paths`, and `result_photos`** | iPad reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`; omitting `mobile_photo_paths` means extra evidence photos are invisible on the iPad after sync |
| 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. | | 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row |
| 32 | **Do not sync an issue when its parent `LocalInspection.syncStatus == "failed"`** | Submitting without `inspection_id` creates orphaned server records; mark issue `"failed"` instead | | 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; use `~.any()` which emits `NOT EXISTS` |
| 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'...'` | | 43 | **`issues.index()` outerjoin must precede all filters** | Both customer-scope and facility_filter blocks reference `Area.facility_id` |
| 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 | | 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. |
| 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 | | 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]` |
| 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 | | 46 | **Facility deduplication in `pullReferenceData()` on iOS** | Server may return same facility ID multiple times; deduplicate before upsert using `seenFacilityIds = Set<Int>()` |
| 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. |
--- ---