diff --git a/CLAUDE.md b/CLAUDE.md index 2c09af2..b1d6fd4 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:** April 2026 (Phase 12 complete + post-review hardening + UX improvements) +> **Last reviewed:** May 2026 (Phase B complete — iPad offline inspection app) --- @@ -16,18 +16,19 @@ 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)](#9-mobile-api-phase-7) -10. [Notification System](#10-notification-system) -11. [SLA Engine](#11-sla-engine) -12. [Audit Trail](#12-audit-trail) -13. [PDF Export](#13-pdf-export) -14. [Scheduled Reports](#14-scheduled-reports) -15. [Rate Limiting](#15-rate-limiting) -16. [Alembic Migration Chain](#16-alembic-migration-chain) -17. [Frontend Conventions](#17-frontend-conventions) -18. [Infrastructure](#18-infrastructure) -19. [Known Constraints & Hard Rules](#19-known-constraints--hard-rules) -20. [Change Philosophy](#20-change-philosophy) +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) --- @@ -42,7 +43,8 @@ - **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 (Phase 7) for a React Native / Expo mobile app +- **Mobile API** — JWT-authenticated REST layer for the iPad native app +- **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B complete) The application is actively deployed in production and maintained by a single developer/administrator. @@ -67,7 +69,9 @@ 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 | -| Mobile client | React Native + Expo (Expo Go for dev; cloud Mac for iOS builds) | +| **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()`) | --- @@ -78,54 +82,25 @@ The application is actively deployed in production and maintained by a single de lt_janitorial_quality_control/ ├── app/ │ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager -│ ├── add_form_schema.py # One-off migration helper (safe re-run) -│ ├── api/ # Mobile REST API (Phase 7) +│ ├── api/ # Mobile REST API │ │ ├── __init__.py # api_bp parent blueprint + register_api() -│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*; rate-limited login/refresh +│ │ ├── 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/ -│ │ ├── __init__.py -│ │ ├── api_token.py # RefreshToken, DeviceToken -│ │ ├── audit.py # AuditLog -│ │ ├── facility.py # Facility, Area -│ │ ├── inspection.py # InspectionTemplate, ChecklistItem, Inspection, InspectionResult -│ │ ├── issue.py # Issue, IssueComment, IssueFollower -│ │ ├── notification.py # Notification, NotificationPreference + event constants -│ │ ├── notification_matrix.py -│ │ ├── project.py # Project, CustomerAssignment -│ │ ├── scheduled_report.py # ScheduledReport -│ │ └── user.py # User (password-setup workflow, display_name) -│ ├── routes/ -│ │ ├── auth.py # /auth/* — login rate-limited; _safe_next() open-redirect guard -│ │ ├── customers.py # /customers/* — _safe_referrer() helper; expired invitation banner -│ │ ├── dashboard.py # / — single open_issues query (len() not .count()) -│ │ ├── facilities.py # /facilities/* -│ │ ├── inspections.py # /inspections/* — passes now=now_eastern() for stale badge -│ │ ├── issues.py # /issues/* — quick-assign AJAX; facility filter; SLA column -│ │ ├── notifications.py # /notifications/* — send-digest, check-sla, cleanup-tokens -│ │ ├── projects.py # /projects/* -│ │ ├── reports.py # /reports/* -│ │ ├── scheduled_reports.py # /scheduled-reports/* -│ │ ├── templates.py # /templates/* -│ │ └── audit.py # /audit/* +│ │ ├── 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/ -│ │ ├── css/ipad_responsive.css -│ │ └── uploads/ │ ├── templates/ -│ │ ├── base.html # Active nav tab styling; request.endpoint-based active detection -│ │ ├── _sla_badge.html -│ │ └── ... │ └── utils/ -│ ├── audit.py # log_action() + ACTION_* constants -│ ├── decorators.py # @admin_required, @supervisor_required, @project_manager_required, @customer_required -│ ├── forms.py # All WTForms (AreaForm includes 'floor' type) -│ ├── notifications.py # notify(), notify_by_matrix(), send_pending_digests() -│ ├── pdf_export.py # ReportLab PDF generation -│ ├── scope.py # get_customer_scope() -│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts() -│ └── time_utils.py # now_eastern() ├── migrations/ │ └── versions/ │ ├── phase1_projects_roles.py @@ -135,10 +110,45 @@ lt_janitorial_quality_control/ │ ├── phase9_user_full_name.py │ ├── phase10_customer_password_setup.py │ ├── phase11_director_role.py -│ └── phase12_performance_indexes.py ← HEAD +│ ├── 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 # Includes Flask-Limiter +├── requirements.txt ├── run.py └── wsgi.py ``` @@ -159,8 +169,8 @@ lt_janitorial_quality_control/ | `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: `send-digest`, `check-sla`, `cleanup-tokens`, `scheduled-reports/run` | -| `REDIS_URL` | Optional. e.g. `redis://127.0.0.1:6379/0`. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. Falls back to in-process memory if absent. | +| `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 @@ -191,13 +201,7 @@ users: id, username (unique, indexed), full_name, email (unique, indexed), **Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer` -**Key property:** `display_name` → `full_name.strip()` or falls back to `username`. Use this everywhere in templates — never use `.username` for display. - -**Expired invitations:** Customer accounts with `password_set=False` and `set_password_token_expires < now_eastern()` are flagged on the `/customers` index with an inline Resend button. - -**Customer self-service setup:** When a customer clicks their invitation link, `set_password.html` prompts them to choose their own **username** (replacing the auto-generated placeholder) and set a **password**. The `set_password` route saves `form.username.data` to `user.username` before committing. The `SetPasswordForm` validates username uniqueness inline via `validate_username()`. - -**Token verification:** `User.verify_set_password_token()` guards in order: token present → DB lookup → `password_set=False` check → expiry check → `hmac.compare_digest()` constant-time comparison. The `password_set=False` guard ensures accounts already activated cannot be re-used via a stale token. `compare_digest` is wrapped in `try/except` to safely handle type mismatches. +**Key property:** `display_name` → `full_name.strip()` or falls back to `username`. ### Facility / Area @@ -216,42 +220,30 @@ customer_assignments: id, user_id, project_id, facility_id (nullable) UniqueConstraint(user_id, project_id, facility_id) ``` -**UI terminology:** "Project" is displayed as **"Contract"** throughout the UI. All backend identifiers (`project_id`, class `Project`, routes `projects.*`) are unchanged. - -`facility_id = NULL` → access to all active facilities in that project. - ### 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 + completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note, + mobile_local_id VARCHAR(64) nullable indexed ← Phase B ``` -**Indexed columns (Phase 12):** `status`, `facility_id`, `inspector_id`, `inspection_date` +**`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. -**Status display:** `completed` renders as **"Submitted"** in the UI. DB value unchanged. - -**Stale badge:** In-progress inspections older than 24 hours show a `⚠ Stale` badge. Route passes `now=now_eastern()` to the list template. - ### Issue ``` issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical), description, photo_path, status (open/in_progress/resolved/pending_verification), assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON), - verified_by, verified_at, verification_note, sla_notified + verified_by, verified_at, verification_note, sla_notified, + mobile_local_id VARCHAR(64) nullable indexed ← Phase B ``` -**Indexed columns (Phase 12):** `status`, `severity`, `assigned_to`, `reported_at` - -**Issue status flow:** `open` → `in_progress` → `pending_verification` → `resolved` - -**Quick-assign:** `POST /issues//quick-assign` — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail. - -**Assignable staff roles:** `admin`, `director`, `inspector` — all three appear in the assignment dropdown on the issue view, create, and list quick-assign forms. `admin` was previously absent from the view/create dropdowns; corrected. +**`mobile_local_id`:** Same idempotency pattern as `inspections.mobile_local_id`. ### Notification / NotificationPreference @@ -260,8 +252,6 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id, ev notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency ``` -**Pause all emails:** Preferences page has a "Pause All Emails" toggle that bulk-disables all email toggles client-side. - ### NotificationMatrix ``` @@ -272,7 +262,7 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON) ### AuditLog ``` -audit_logs: id, user_id (nullable, SET NULL on delete), username (snapshot), user_role (snapshot), +audit_logs: id, user_id (nullable), username (snapshot), user_role (snapshot), action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address ``` @@ -285,10 +275,6 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version UniqueConstraint(user_id, device_id) ``` -**Token cleanup:** -1. **Passive** — on every successful API login, expired/revoked tokens for that user are deleted -2. **Cron** — `POST /notifications/cleanup-tokens?token=` deletes all globally - --- ## 6. Role & Permission Matrix @@ -326,21 +312,25 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version | Blueprint | Prefix | Notable routes | |---|---|---| -| `auth` | `/auth` | `/login` (rate-limited 20/min), `/logout`, `/profile`, `/users/*`, `/notification-matrix` | +| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` | | `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) | -| `facilities` | `/facilities` | CRUD + area management; list grouped by Contract with collapsible sections; detail page has Back button | +| `facilities` | `/facilities` | CRUD + area management | | `projects` | `/projects` | CRUD + customer assignment management | -| `customers` | `/customers` | list (expired invitation banner), invite (name + email only), set-password (customer chooses username + password), manage, import CSV | -| `inspections` | `/inspections` | list (stale badge, `now` passed from route), start (template + facility only — area and notes removed), execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect | +| `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 (SLA column, facility filter, quick-assign dropdown), view, create, update, verify, comment, follow/unfollow, verification queue, delete | -| `issues` | `/issues` | `POST //quick-assign` — JSON AJAX, admin/director only | -| `notifications` | `/notifications` | list, mark-read, mark-all-read, preferences (pause-all toggle), `send-digest` (cron), `check-sla` (cron), `cleanup-tokens` (cron) | +| `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` (10/min), `/auth/refresh` (30/min), `/auth/logout`, `/auth/me`, `/devices/register` | +| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` | +| `api_facilities` | `/api/v1` | `/facilities`, `/facilities//areas` | +| `api_templates` | `/api/v1` | `/templates`, `/templates/` | +| `api_inspections` | `/api/v1` | `POST /inspections`, `PATCH /inspections/` | +| `api_issues` | `/api/v1` | `POST /issues` | +| `api_photos` | `/api/v1` | `POST /photos/upload` | --- @@ -353,7 +343,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version `log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. ### `scope.py` -`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff. Uses a single bulk `Facility.project_id.in_(...)` query for project-scoped assignments — never N+1. +`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. @@ -363,14 +353,36 @@ All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes ### `sla.py` `sla_status(issue)` → `'ok'` | `'at_risk'` | `'breached'` | `None` (resolved). -`send_sla_alerts()` — called by cron, deduplicates via `issue.sla_notified`. ### `pdf_export.py` ReportLab-based. 12-column grid must be preserved — never collapse in PDF views. --- -## 9. Mobile API (Phase 7) +## 9. Mobile API (Phase 7 / Phase A / Phase B) + +### CSRF Exemption Pattern — Critical + +**`csrf.exempt(api_bp)` does NOT cascade to sub-blueprints.** Flask-WTF's `_is_exempt()` checks the leaf blueprint object. Each child blueprint must be exempted individually in `app/__init__.py`: + +```python +from app.api import register_api, api_bp +from app.api.auth import bp as _api_auth_bp +from app.api.facilities import bp as _api_facilities_bp +from app.api.templates import bp as _api_templates_bp +from app.api.inspections import bp as _api_inspections_bp +from app.api.issues import bp as _api_issues_bp +from app.api.photos import bp as _api_photos_bp +csrf.exempt(_api_auth_bp) +csrf.exempt(_api_facilities_bp) +csrf.exempt(_api_templates_bp) +csrf.exempt(_api_inspections_bp) +csrf.exempt(_api_issues_bp) +csrf.exempt(_api_photos_bp) +register_api(app) +``` + +**Every new Phase C+ blueprint must add its own `csrf.exempt()` line here before `register_api(app)`.** Failing to do so produces a `"The CSRF token is missing."` error on all POST requests to that blueprint. ### Auth Flow 1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex) @@ -383,25 +395,156 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view |---|---| | `POST /api/v1/auth/login` | 10/min, 3/sec | | `POST /api/v1/auth/refresh` | 30/min, 5/sec | -| `POST /auth/login` (web) | 20/min, 5/sec | -### Refresh Token Cleanup -- Raw token never stored — SHA-256 hash only -- Passive: expired/revoked tokens purged per-user on each login -- Cron: `POST /notifications/cleanup-tokens` +### Phase A Endpoints + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user | +| `GET /api/v1/facilities//areas` | jwt_required | Areas for a facility | +| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) | +| `GET /api/v1/templates/` | jwt_required | Full template with form_schema | + +Customer role is blocked from template endpoints (`_ALLOWED_ROLES` check). Facility endpoints honour `get_customer_scope()`. + +### Phase B Endpoints + +| Endpoint | Auth | Description | +|---|---|---| +| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` | +| `PATCH /api/v1/inspections/` | jwt_required | Update inspection (draft → completed) | +| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id` | +| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` | + +### Idempotency Pattern + +All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt: -### CSRF Exemption Pattern ```python -csrf.exempt(api_bp) # BEFORE register_api(app) — order matters -register_api(app) +existing = Model.query.filter_by(mobile_local_id=mobile_local_id).first() +if existing: + return api_ok({'id': existing.id, 'duplicate': True}) ``` -### Phase 2 (planned) -`api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos` — all stubbed in `app/api/__init__.py`. +This protects against double-submission when the network fails after the server commits but before the device receives the response. Web-created records have `mobile_local_id = NULL`. + +### Photo Upload Flow + +Photos are uploaded **before** the inspection or issue is submitted: +1. iPad calls `POST /api/v1/photos/upload` with multipart image +2. Server saves to `app/static/uploads/inspection_photos/` or `issue_photos/` +3. Returns `{ "server_path": "uploads/inspection_photos/uuid.jpg" }` +4. iPad includes `server_path` in the subsequent inspection/issue POST + +### Score Calculation (Server-Side) + +`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly: +- Rating value `0` = unanswered → excluded from total +- pass_fail accepted values: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` +- Returns `float` 0–100 or `None` if no scoreable fields --- -## 10. Notification System +## 10. iPad Native App + +### Platform + +- **Language:** Swift 5.10+ +- **UI:** SwiftUI (iPad-only, all four orientations) +- **Local DB:** SwiftData (iOS 17+ required) +- **Networking:** URLSession async/await +- **Connectivity:** NWPathMonitor (Network.framework) +- **Token storage:** iOS Keychain (Security.framework) +- **Xcode:** 15+ + +### Offline-First Architecture + +The app follows the **outbox pattern** — every inspector action writes to SwiftData first; the server is a secondary destination. + +``` +Inspector action → SwiftData write (always succeeds) → SyncQueue entry + ↓ + NWPathMonitor detects reconnect + ↓ + SyncManager.triggerSync() + 1. Upload pending photos + 2. Submit completed inspections + 3. Submit pending issues + 4. Pull fresh reference data +``` + +### SwiftData Models + +| Model | Purpose | +|---|---| +| `LocalFacility` | Cached facility reference data (read-only on device) | +| `LocalArea` | Cached area reference data | +| `LocalTemplate` | Cached template + `formSchemaJSON` (raw JSON string) | +| `LocalInspection` | Inspector-created inspection records | +| `LocalIssue` | Issues flagged during inspections | +| `PendingPhoto` | Photos awaiting upload; tracks `localFilePath` → `serverPath` | +| `SyncQueueEntry` | Outbox queue (currently unused directly — filtering done in Swift) | + +### LocalInspection Status Flow + +``` +"draft" → "completed" → "synced" + → "failed" (after 5 retries) +``` + +`syncStatus` is separate from `status`: +- `status`: inspector workflow state +- `syncStatus`: server submission state (`"pending"` | `"synced"` | `"failed"`) + +### SyncManager Key Behaviours + +- **Fetch-then-filter pattern:** All `processPhotoQueue`, `processInspectionQueue`, `processIssueQueue` fetch all records and filter in Swift rather than using `#Predicate` with string literals. This avoids a SwiftData `#Predicate` macro type-inference bug with string comparisons across model boundaries. +- **Sequential reference data fetch:** `pullReferenceData()` uses sequential `await` (not `async let`) to avoid Swift 6 actor-isolation warnings on `Decodable` structs. +- **Photo-before-inspection ordering:** `processPhotoQueue` runs before `processInspectionQueue`. An inspection is only submitted after all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`. +- **Retry limit:** 5 retries per item before marking `syncStatus = "failed"`. + +### APIClient Key Behaviours + +- **401 auto-retry:** On a 401 response, `refreshAccessToken()` is called once and the original request is retried. If refresh fails, `APIError.notAuthenticated` is thrown. +- **Keychain token storage:** `kSecAttrAccessibleAfterFirstUnlock` — tokens survive device reboot, accessible for background sync. +- **Photo upload:** Multipart `form-data` built manually (no third-party library). Boundary is a UUID string. + +### FormFieldView — Supported Field Types + +All types from the web app's `INPUT_FIELD_TYPES` set are rendered: + +| Type | SwiftUI renderer | +|---|---| +| `text`, `email` | `TextField` | +| `textarea` | `TextEditor` | +| `number` | `TextField` + `.decimalPad` | +| `date` | `DatePicker` | +| `checkbox` | `Toggle` | +| `checkbox_group` | Custom multi-select buttons | +| `radio` | Custom radio buttons | +| `select` | `Picker(.menu)` | +| `rating` | Custom star rating (tap same star to clear) | +| `pass_fail` | Two-button Pass/Fail control | +| `signature` | `PKCanvasView` (PencilKit) | +| `image` | `UIImagePickerController` sheet → local file save | +| `table` | `Grid` of `TextField` | +| `section`, `label` | Display-only `Text` | + +### Known iOS-Specific Constraints + +| # | Constraint | Rationale | +|---|---|---| +| 1 | **`import Combine` required for `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors | +| 2 | **`NavigationSplitView` — no `selection:` binding** | `init(selection:content:)` unavailable on iPadOS 17; use `List` with manual `Button` + `@State var selectedTab` | +| 3 | **`#Predicate` — no string literal comparisons across model boundaries** | SwiftData macro type-inference bug; fetch all + filter in Swift instead | +| 4 | **`async let` — Swift 6 actor-isolation warnings on Decodable** | Use sequential `await` calls for reference data fetches | +| 5 | **PencilKit requires framework linkage** | Add `PencilKit.framework` under Target → Frameworks, Libraries, and Embedded Content | +| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls | +| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for background sync** | Tokens must be readable when the app is woken by BGTaskScheduler | + +--- + +## 11. Notification System ### Event Constants (`app/models/notification.py`) ``` @@ -419,14 +562,9 @@ customer_inspection_completed | `POST /notifications/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` | | `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` | -### Implicit Notifications (always sent, not matrix-controlled) -- Issue **assignee** on assignment/status/comment -- Issue **followers** on any update -- SLA **assignee + followers** on SLA events - --- -## 11. SLA Engine +## 12. SLA Engine | Severity | Window | At-Risk | |---|---|---| @@ -435,41 +573,33 @@ customer_inspection_completed | medium | 72h | 54h | | low | 168h | 126h | -SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron notifications. - -**SLA filter on issues list:** When `?sla=` is active, the full matching result set is loaded and filtered in Python, then wrapped in `_SLAFilteredPage` (defined at the top of `routes/issues.py`). This satisfies the template's pagination interface (`.items`, `.page`, `.pages`, `.iter_pages()`) without any template changes. Pagination nav is automatically suppressed via the existing `{% if issues.pages > 1 %}` guard. +`issue.sla_notified` prevents duplicate cron notifications. --- -## 12. Audit Trail +## 13. Audit Trail - Admin-only at `/audit/` — director is excluded - Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT` -- Immutable — never updated or deleted through the application (purge UI exists for old records) -- IP from `X-Forwarded-For` or `request.remote_addr` - -**`ACTION_EXPORT` coverage:** Both `/reports/export/inspections` and `/reports/export/issues` call `log_action(ACTION_EXPORT, ...)` before streaming the CSV response. - -**Logging coverage:** All route modules now import `logging` and define a module-level `logger = logging.getLogger(__name__)`. Previously missing from `facilities.py`, `templates.py`, `reports.py`, and `dashboard.py`. +- Mobile API routes call `log_action()` for all create/update operations +- Immutable — never updated or deleted through the application --- -## 13. PDF Export +## 14. PDF Export ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF. --- -## 14. Scheduled Reports +## 15. Scheduled Reports Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`. Cron: `POST /scheduled-reports/run?secret=` --- -## 15. Rate Limiting - -Initialised in `app/__init__.py` as a module-level extension: +## 16. Rate Limiting ```python limiter = Limiter( @@ -477,69 +607,57 @@ limiter = Limiter( default_limits = [], storage_uri = os.environ.get('REDIS_URL', 'memory://'), ) -limiter.init_app(app) ``` -Import in routes: `from app import limiter`, then `@limiter.limit('N per period')`. - -**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0` in the server environment. Counters are then shared across all Gunicorn workers and rate limits are correctly enforced. - -**Development:** `REDIS_URL` absent → falls back to `memory://` (in-process, single-worker). No Redis install required locally. - -> **`redis` package** is in `requirements.txt`. Install it with `pip install -r requirements.txt` before setting `REDIS_URL`. +**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`. Counters shared across all Gunicorn workers. --- -## 16. Alembic Migration Chain +## 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 ← HEAD + → phase12_performance_indexes → phase_b_mobile_local_id ← 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. + ### MySQL ENUM Change Protocol (3 steps — always follow) ```sql --- 1. Expand to include both values +-- 1. Expand ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL; --- 2. Migrate data +-- 2. Migrate UPDATE users SET role = 'director' WHERE role = 'supervisor'; --- 3. Contract to remove old value +-- 3. Contract ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL; ``` -### MySQL Compatibility -`CREATE INDEX IF NOT EXISTS` requires MySQL ≥ 8.0.1. For 5.7 compatibility use `information_schema.statistics` existence checks — see `phase12_performance_indexes.py` for the reusable `_index_exists()` pattern. +### 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) -Model.query.get_or_404(id) # CORRECT obj = db.session.get(Model, id) if obj is None: abort(404) ``` -> All `Model.query.get()` calls have been replaced — including `load_user()` in `models/user.py` and three calls in `utils/notifications.py`. - --- -## 17. Frontend Conventions +## 18. Frontend Conventions ### Active Nav Tab -```css -/* base.html