From a5fe4cbfa6cbee56b43c0cd12e8030e529874ab2 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 21 May 2026 14:37:26 -0400 Subject: [PATCH] 05/21 Update Claude.md --- CLAUDE.md | 390 ++++++++++++++++-------------------------------------- 1 file changed, 113 insertions(+), 277 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d3f67ee..78ea8ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **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) +> **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) 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) +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) 11. [Notification System](#11-notification-system) 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 | | Server | Gunicorn (sync workers) behind Nginx | | 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 auth storage** | **iOS Keychain (Security.framework)** | | 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) │ │ ├── templates.py # /api/v1/templates/* (Phase A) │ │ ├── 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) │ │ ├── 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) +│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B) +│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19) +│ │ └── ... +│ ├── routes/ │ ├── static/ +│ │ └── uploads/ # UPLOAD_FOLDER root +│ │ ├── inspection_photos/ +│ │ ├── issue_photos/ # photo_path and mobile_photo_paths files +│ │ └── issue_result_photos/ # result_photos files (web-added resolution photos) │ ├── templates/ +│ │ └── issues/ +│ │ ├── view.html # Shows photo_path + mobile_photo_paths under "Photo Evidence" +│ │ └── issues_view.html # Same photo evidence logic │ └── 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 +│ └── phase19_issue_mobile_photos.py ← HEAD +└── ... ``` --- @@ -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), - 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), reported_at, resolved_at, result_notes, result_photos (JSON), + mobile_photo_paths (JSON), ← Phase 19 verified_by, verified_at, verification_note, sla_notified, - mobile_local_id VARCHAR(64) nullable indexed ← Phase B + 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//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 @@ -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 ``` -**`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 ``` @@ -333,7 +303,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version | `api_facilities` | `/api/v1` | `/facilities`, `/facilities//areas` | | `api_templates` | `/api/v1` | `/templates`, `/templates/` | | `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/` | -| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/`, `PATCH /issues//status` | +| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/`, `PATCH /issues//status`, `PATCH /issues//photos` ← Phase 19 | | `api_photos` | `/api/v1` | `POST /photos/upload` | | `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. ### `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_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.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. +**`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//photos` route) inherits the exemption already applied to `_api_issues_bp`. **Every new blueprint must add its own `csrf.exempt()` line before `register_api(app)`.** ### Auth Flow 1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex) @@ -397,12 +346,6 @@ register_api(app) 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 | @@ -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 | 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/issues` | jwt_required | Create issue; idempotent via `mobile_local_id`; accepts `result_photos` list stored in `mobile_photo_paths` | | `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` | ### Phase C Endpoints @@ -428,150 +369,72 @@ Customer role is blocked from template endpoints (`_ALLOWED_ROLES` check). Facil | 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/` | jwt_required | Single issue detail — inspectors scoped to assigned only | -| `PATCH /api/v1/issues//status` | jwt_required | Update issue status — inspectors scoped to assigned only | -| `GET /api/v1/notifications` | jwt_required | Unread notifications for current user; accepts `?since=` | +| `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/` | jwt_required | Single issue detail | +| `PATCH /api/v1/issues//status` | jwt_required | Update issue status | +| `GET /api/v1/notifications` | jwt_required | Unread notifications; accepts `?since=` | | `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//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 -- **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/` and `PATCH /issues//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=`. -- `_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/` and `PATCH /issues//status` and `PATCH /issues//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//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()` filter on the iOS side AND the upsert map (`facilityMap`) on the server side. ### Idempotency Pattern -All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt: - -```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 +All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt, check for existing record and return `{ 'duplicate': True }` without inserting. Web-created records have `mobile_local_id = NULL`. ### Score Calculation (Server-Side) -`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly: -- Rating value `0` = unanswered → excluded from total -- pass_fail accepted values: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` -- Returns `float` 0–100 or `None` if no scoreable fields +`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly. Rating value `0` = unanswered → excluded. Returns `float` 0–100 or `None` if no scoreable fields. --- ## 10. iPad Native App -### Platform +See the iOS app's own `CLAUDE.md` for full details. Key integration points: -- **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 | +- App connects to `jqc.ltservicesinc.com` (primary) or `jqc1.ltservicesinc.com` (secondary) — **server is user-selectable at login and in Settings**. +- Server selection is persisted to `UserDefaults` via `ServerConfig`. Switching server in Settings triggers a logout confirmation alert and clears all server-pulled SwiftData records (`serverId != nil`) before logout. +- All photo evidence from the iPad routes through `mobile_photo_paths` on the server — never through `result_photos`. --- @@ -585,13 +448,11 @@ 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_ISSUE_FLAGGED = 'issue_flagged' 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 | @@ -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 → phase15_audit_log_indexes → phase16_notifications_columns → 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`. -Uses `INFORMATION_SCHEMA.COLUMNS` and `INFORMATION_SCHEMA.STATISTICS` existence checks — safe to re-run. +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. -### 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. +**Deploy order for phase19:** +```bash +flask db upgrade # add mobile_photo_paths column +sudo systemctl restart gunicorn +``` ### MySQL ENUM Change Protocol (3 steps — always follow) ```sql @@ -708,8 +548,8 @@ 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. +- **`batch_alter_table`** — SQLite-only workaround; do not use for MySQL migrations. +- **Migration deploy order:** Always run `flask db upgrade` before swapping `app/__init__.py` if the new version imports models that reference the new columns. ### Deprecated SQLAlchemy Patterns ```python @@ -748,6 +588,10 @@ Always use `user.display_name` in templates — never `.username` for display pu ### Real-Time **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 @@ -763,7 +607,6 @@ 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` @@ -801,7 +644,7 @@ timeout = 30 | 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` | +| 15 | **Open-redirect guards** | `safe_redirect_url()` in `app/utils/decorators.py` | | 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.12** | Use `INFORMATION_SCHEMA.STATISTICS` check | | 17 | **`batch_alter_table` is SQLite-only** | Use direct `ALTER TABLE` for MySQL migrations | | 18 | **Set `REDIS_URL` in production** | `memory://` is per-process; Gunicorn needs Redis for accurate shared counters | @@ -810,26 +653,19 @@ timeout = 30 | 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/`, `PATCH /issues//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. | +| 24–29 | *(iOS-specific — see iOS CLAUDE.md)* | | +| 30 | **Do NOT add an explicit `Issue.area` relationship** | `Area.issues` declares `backref='area'`, supplying `Issue.area` automatically. A second declaration raises `ConflictingBackreferences` at startup. | +| 31 | **Do not sync an issue when its parent `LocalInspection.syncStatus == "failed"`** | Submitting without `inspection_id` creates orphaned server records | +| 32 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes | +| 33–38 | *(field ID casting, photo sentinel, notify event_type, follow-up, OperationalError)* | See prior rule entries | +| 39 | **Issue API scope: inspectors see assigned OR reported issues** | `GET /issues`, `GET /issues/`, `PATCH /issues//status`, `PATCH /issues//photos` all enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role | +| 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 | +| 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row | +| 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; use `~.any()` which emits `NOT EXISTS` | +| 43 | **`issues.index()` outerjoin must precede all filters** | Both customer-scope and facility_filter blocks reference `Area.facility_id` | +| 44 | **iPad evidence photos go to `mobile_photo_paths`, never `result_photos`** | `result_photos` is exclusively for resolution photos added via the web update form. Mixing them causes evidence photos to appear under "Resolution Details" on the web. | +| 45 | **`PATCH /issues//photos` is idempotent — merge, never overwrite** | Retry-safe: `merged = existing + [p for p in new_photos if p not in existing]` | +| 46 | **Facility deduplication in `pullReferenceData()` on iOS** | Server may return same facility ID multiple times; deduplicate before upsert using `seenFacilityIds = Set()` | ---