05/02 Update CLAUDE.md

This commit is contained in:
Nguyen Ngo
2026-05-02 13:23:59 -04:00
parent d36c0d2e1e
commit 4505c66bc9
+301 -184
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:** 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) 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)](#9-mobile-api-phase-7) 9. [Mobile API (Phase 7 / Phase A / Phase B)](#9-mobile-api-phase-7--phase-a--phase-b)
10. [Notification System](#10-notification-system) 10. [iPad Native App](#10-ipad-native-app)
11. [SLA Engine](#11-sla-engine) 11. [Notification System](#11-notification-system)
12. [Audit Trail](#12-audit-trail) 12. [SLA Engine](#12-sla-engine)
13. [PDF Export](#13-pdf-export) 13. [Audit Trail](#13-audit-trail)
14. [Scheduled Reports](#14-scheduled-reports) 14. [PDF Export](#14-pdf-export)
15. [Rate Limiting](#15-rate-limiting) 15. [Scheduled Reports](#15-scheduled-reports)
16. [Alembic Migration Chain](#16-alembic-migration-chain) 16. [Rate Limiting](#16-rate-limiting)
17. [Frontend Conventions](#17-frontend-conventions) 17. [Alembic Migration Chain](#17-alembic-migration-chain)
18. [Infrastructure](#18-infrastructure) 18. [Frontend Conventions](#18-frontend-conventions)
19. [Known Constraints & Hard Rules](#19-known-constraints--hard-rules) 19. [Infrastructure](#19-infrastructure)
20. [Change Philosophy](#20-change-philosophy) 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 - **Notification** system (in-app + email) driven by an admin-controlled matrix
- **Reports** — on-demand PDF/CSV scorecards and scheduled email digests - **Reports** — on-demand PDF/CSV scorecards and scheduled email digests
- **Audit trail** — immutable log of every create/update/delete action - **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. 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 | | 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 |
| 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()`) | | 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/ lt_janitorial_quality_control/
├── app/ ├── app/
│ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager │ ├── __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
│ ├── api/ # Mobile REST API (Phase 7)
│ │ ├── __init__.py # api_bp parent blueprint + register_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 │ │ ├── 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/
│ │ ├── __init__.py │ │ ├── inspection.py # Inspection now has mobile_local_id column (Phase B)
│ │ ├── api_token.py # RefreshToken, DeviceToken │ │ ├── issue.py # Issue now has mobile_local_id column (Phase B)
│ │ ── audit.py # AuditLog │ │ ── ... # (all other models unchanged)
│ ├── facility.py # Facility, Area │ ├── routes/ # (unchanged from Phase 12)
│ │ ├── 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/*
│ ├── static/ │ ├── static/
│ │ ├── css/ipad_responsive.css
│ │ └── uploads/
│ ├── templates/ │ ├── templates/
│ │ ├── base.html # Active nav tab styling; request.endpoint-based active detection
│ │ ├── _sla_badge.html
│ │ └── ...
│ └── utils/ │ └── 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/ ├── migrations/
│ └── versions/ │ └── versions/
│ ├── phase1_projects_roles.py │ ├── phase1_projects_roles.py
@@ -135,10 +110,45 @@ lt_janitorial_quality_control/
│ ├── phase9_user_full_name.py │ ├── phase9_user_full_name.py
│ ├── phase10_customer_password_setup.py │ ├── phase10_customer_password_setup.py
│ ├── phase11_director_role.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 ├── config.py
├── gunicorn_config.py ├── gunicorn_config.py
├── requirements.txt # Includes Flask-Limiter ├── requirements.txt
├── run.py ├── run.py
└── wsgi.py └── wsgi.py
``` ```
@@ -159,8 +169,8 @@ lt_janitorial_quality_control/
| `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects flags | | `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects flags |
| `APP_BASE_URL` | Full URL for email links | | `APP_BASE_URL` | Full URL for email links |
| `MAIL_DEFAULT_SENDER` | From address | | `MAIL_DEFAULT_SENDER` | From address |
| `DIGEST_SECRET` | Authenticates all cron endpoints: `send-digest`, `check-sla`, `cleanup-tokens`, `scheduled-reports/run` | | `DIGEST_SECRET` | Authenticates all cron endpoints |
| `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. | | `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
### Email SSL Auto-Detection ### 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` **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. **Key property:** `display_name``full_name.strip()` or falls back to `username`.
**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.
### Facility / Area ### Facility / Area
@@ -216,42 +220,30 @@ customer_assignments: id, user_id, project_id, facility_id (nullable)
UniqueConstraint(user_id, project_id, facility_id) 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 ### Inspection
``` ```
inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date, inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date,
overall_score, status (in_progress/completed/flagged), notes, form_data (JSON), 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. **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 ### Issue
``` ```
issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical), issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical),
description, photo_path, status (open/in_progress/resolved/pending_verification), description, photo_path, status (open/in_progress/resolved/pending_verification),
assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON), 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` **`mobile_local_id`:** Same idempotency pattern as `inspections.mobile_local_id`.
**Issue status flow:** `open``in_progress``pending_verification``resolved`
**Quick-assign:** `POST /issues/<id>/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.
### Notification / NotificationPreference ### 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 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 ### NotificationMatrix
``` ```
@@ -272,7 +262,7 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
### AuditLog ### 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 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) 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=<DIGEST_SECRET>` deletes all globally
--- ---
## 6. Role & Permission Matrix ## 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 | | 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) | | `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 | | `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 | | `customers` | `/customers` | list, invite, set-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 | | `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 | | `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` | list, view, create, update, verify, comment, follow/unfollow, verification queue, delete, quick-assign |
| `issues` | `/issues` | `POST /<id>/quick-assign` — JSON AJAX, admin/director only | | `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
| `notifications` | `/notifications` | list, mark-read, mark-all-read, preferences (pause-all toggle), `send-digest` (cron), `check-sla` (cron), `cleanup-tokens` (cron) |
| `audit` | `/audit` | list (admin only), view, purge | | `audit` | `/audit` | list (admin only), view, purge |
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger |
| `api` | `/api/v1` | parent blueprint | | `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/<id>/areas` |
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
| `api_inspections` | `/api/v1` | `POST /inspections`, `PATCH /inspections/<id>` |
| `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()`. `log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`.
### `scope.py` ### `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` ### `forms.py`
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role. 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.py`
`sla_status(issue)``'ok'` | `'at_risk'` | `'breached'` | `None` (resolved). `sla_status(issue)``'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
`send_sla_alerts()` — called by cron, deduplicates via `issue.sla_notified`.
### `pdf_export.py` ### `pdf_export.py`
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views. 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 ### 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)
@@ -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/login` | 10/min, 3/sec |
| `POST /api/v1/auth/refresh` | 30/min, 5/sec | | `POST /api/v1/auth/refresh` | 30/min, 5/sec |
| `POST /auth/login` (web) | 20/min, 5/sec |
### Refresh Token Cleanup ### Phase A Endpoints
- Raw token never stored — SHA-256 hash only
- Passive: expired/revoked tokens purged per-user on each login | Endpoint | Auth | Description |
- Cron: `POST /notifications/cleanup-tokens` |---|---|---|
| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user |
| `GET /api/v1/facilities/<id>/areas` | jwt_required | Areas for a facility |
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) |
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema |
Customer role is blocked from template endpoints (`_ALLOWED_ROLES` check). Facility endpoints honour `get_customer_scope()`.
### Phase B Endpoints
| Endpoint | Auth | Description |
|---|---|---|
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) |
| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id` |
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` |
### Idempotency Pattern
All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt:
### CSRF Exemption Pattern
```python ```python
csrf.exempt(api_bp) # BEFORE register_api(app) — order matters existing = Model.query.filter_by(mobile_local_id=mobile_local_id).first()
register_api(app) if existing:
return api_ok({'id': existing.id, 'duplicate': True})
``` ```
### Phase 2 (planned) 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`.
`api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos` — all stubbed in `app/api/__init__.py`.
### Photo Upload Flow
Photos are uploaded **before** the inspection or issue is submitted:
1. iPad calls `POST /api/v1/photos/upload` with multipart image
2. Server saves to `app/static/uploads/inspection_photos/` or `issue_photos/`
3. Returns `{ "server_path": "uploads/inspection_photos/uuid.jpg" }`
4. iPad includes `server_path` in the subsequent inspection/issue POST
### Score Calculation (Server-Side)
`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly:
- Rating value `0` = unanswered → excluded from total
- pass_fail accepted values: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant`
- Returns `float` 0100 or `None` if no scoreable fields
--- ---
## 10. 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`) ### 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/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` |
| `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` | | `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 | | Severity | Window | At-Risk |
|---|---|---| |---|---|---|
@@ -435,41 +573,33 @@ customer_inspection_completed
| medium | 72h | 54h | | medium | 72h | 54h |
| low | 168h | 126h | | low | 168h | 126h |
SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron notifications. `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.
--- ---
## 12. Audit Trail ## 13. Audit Trail
- Admin-only at `/audit/` — director is excluded - Admin-only at `/audit/` — director is excluded
- Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT` - Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT`
- Immutable — never updated or deleted through the application (purge UI exists for old records) - Mobile API routes call `log_action()` for all create/update operations
- IP from `X-Forwarded-For` or `request.remote_addr` - Immutable — never updated or deleted through the application
**`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`.
--- ---
## 13. PDF Export ## 14. PDF Export
ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF. 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`. Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`.
Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>` Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
--- ---
## 15. Rate Limiting ## 16. Rate Limiting
Initialised in `app/__init__.py` as a module-level extension:
```python ```python
limiter = Limiter( limiter = Limiter(
@@ -477,69 +607,57 @@ limiter = Limiter(
default_limits = [], default_limits = [],
storage_uri = os.environ.get('REDIS_URL', 'memory://'), 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`. Counters shared across all Gunicorn workers.
**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`.
--- ---
## 16. Alembic Migration Chain ## 17. Alembic Migration Chain
``` ```
phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix
→ phase9_user_full_name → phase10_customer_password_setup → phase11_director_role → 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) ### MySQL ENUM Change Protocol (3 steps — always follow)
```sql ```sql
-- 1. Expand to include both values -- 1. Expand
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL; 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'; 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; ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
``` ```
### MySQL Compatibility ### MySQL Compatibility Rules
`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.
- **`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 ### Deprecated SQLAlchemy Patterns
```python ```python
# WRONG # WRONG
Model.query.get(id) Model.query.get(id)
Model.query.get_or_404(id)
# CORRECT # CORRECT
obj = db.session.get(Model, id) obj = db.session.get(Model, id)
if obj is None: abort(404) 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 ### Active Nav Tab
```css Detected via `request.endpoint.startswith('<blueprint>.')` in each nav `<a>` tag.
/* base.html <style> block */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255,255,255,0.18);
color: #fff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
```
Active state detected via `request.endpoint.startswith('<blueprint>.')` in each nav `<a>` tag.
### Display Names ### Display Names
Always use `user.display_name` in templates — never `.username` for display purposes. Always use `user.display_name` in templates — never `.username` for display purposes.
@@ -556,7 +674,6 @@ Always use `user.display_name` in templates — never `.username` for display pu
### Forms ### Forms
- Flask-WTF CSRF auto-applied to all web forms - Flask-WTF CSRF auto-applied to all web forms
- Manual forms: `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">`
- **Never nest `<form>` tags** — browsers silently discard inner forms - **Never nest `<form>` tags** — browsers silently discard inner forms
### Real-Time ### Real-Time
@@ -564,7 +681,7 @@ Always use `user.display_name` in templates — never `.username` for display pu
--- ---
## 18. Infrastructure ## 19. Infrastructure
### Gunicorn ### Gunicorn
```python ```python
@@ -573,7 +690,6 @@ workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync" worker_class = "sync"
timeout = 30 timeout = 30
``` ```
Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
### Application Logging ### Application Logging
- `RotatingFileHandler``logs/jqc.log` (5 × 5 MB) - `RotatingFileHandler``logs/jqc.log` (5 × 5 MB)
@@ -598,7 +714,7 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
--- ---
## 19. Known Constraints & Hard Rules ## 20. Known Constraints & Hard Rules
| # | Rule | Rationale | | # | Rule | Rationale |
|---|---|---| |---|---|---|
@@ -606,7 +722,7 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
| 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs | | 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs |
| 3 | **3-step MySQL ENUM changes** | Skipping causes data loss | | 3 | **3-step MySQL ENUM changes** | Skipping causes data loss |
| 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail | | 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail |
| 5 | **`CSRFProtect` as extension instance** | Flask-WTF 1.0+ removed per-route `@csrf_exempt` | | 5 | **`csrf.exempt()` on each child blueprint individually** | `csrf.exempt(api_bp)` does NOT cascade; Flask-WTF checks leaf blueprint object only |
| 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators | | 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators |
| 7 | **Score 0 = unanswered** | Excluded from calculation — not the same as scoring zero | | 7 | **Score 0 = unanswered** | Excluded from calculation — not the same as scoring zero |
| 8 | **12-column grid in PDF** | Must not collapse in print/PDF | | 8 | **12-column grid in PDF** | Must not collapse in print/PDF |
@@ -617,24 +733,25 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
| 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_next()` in auth.py; `_safe_referrer()` in customers.py | | 15 | **Open-redirect guards** | `_safe_next()` in auth.py; `_safe_referrer()` in customers.py |
| 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.1** | Use `information_schema` — see phase12 | | 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.12** | Use `INFORMATION_SCHEMA.STATISTICS` check |
| 17 | **Set `REDIS_URL` in production** | `memory://` is per-process; multi-worker Gunicorn needs Redis for accurate shared counters | | 17 | **`batch_alter_table` is SQLite-only** | Use direct `ALTER TABLE` for MySQL migrations |
| 18 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged | | 18 | **Set `REDIS_URL` in production** | `memory://` is per-process; Gunicorn needs Redis for accurate shared counters |
| 19 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only | | 19 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged |
| 20 | **`open_issues` count derived from `len()` not `.count()`** | Avoids a second DB round-trip on the dashboard | | 20 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only |
| 21 | **`supervisor` removed from Python-side ENUM** | Phase 11 migration complete — both DB and model ENUM must stay in sync | | 21 | **`mobile_local_id` idempotency on all mobile write endpoints** | Network retries must not create duplicate records |
| 22 | **SLA filter loads full result set (no paginate)** | SLA status is computed in Python; `_SLAFilteredPage` wrapper satisfies the template pagination interface | | 22 | **Photo upload before inspection/issue submission** | Server path must be known before the parent record is created |
| 23 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks on `verify_set_password_token` | | 23 | **Migration deploy before new `app/__init__.py`** | New init imports models referencing new columns; columns must exist first |
| 24 | **`get_customer_scope()` uses bulk project query** | Single `Facility.project_id.in_(project_ids)` replaces per-assignment loop | | 24 | **`import Combine` required in iOS files using `@Published`** | Swift 5.9+ does not auto-import Combine |
| 25 | **CSV exports always call `log_action(ACTION_EXPORT, ...)`** | Data exports are compliance-relevant audit events | | 25 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 |
| 26 | **`StartInspectionForm` has no `area_id` or `notes` fields** | Both removed from the start page; `Inspection` constructor receives `area_id=None, notes=None` explicitly | | 26 | **SwiftData `#Predicate` — fetch all + filter in Swift for string comparisons** | Macro type-inference bug with string literals across model type boundaries |
| 27 | **Customer set-password page collects username + password** | `SetPasswordForm` includes `username` field; route saves it to `user.username`, replacing the auto-generated placeholder | | 27 | **Sequential `await` for reference data fetches in SyncManager** | `async let` causes Swift 6 actor-isolation warnings on Decodable structs |
| 28 | **`verify_set_password_token` requires `password_set=False`** | Prevents reuse of a stale token on an already-activated account | | 28 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks |
| 29 | **Facilities list grouped by Contract in route, not template** | `list_facilities()` builds a `grouped` OrderedDict before rendering; template iterates `grouped`, not the flat `facilities` list | | 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 |
--- ---
## 20. Change Philosophy ## 21. Change Philosophy
1. **Surgical, additive patches** — smallest possible change to achieve the goal 1. **Surgical, additive patches** — smallest possible change to achieve the goal
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise 2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise