699 lines
35 KiB
Markdown
699 lines
35 KiB
Markdown
# Claude.md — JQC Developer Reference
|
||
|
||
> **Audience:** AI assistants and developers working on this codebase.
|
||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||
> **Last reviewed:** May 2026 (Phase 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal)
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Project Overview](#1-project-overview)
|
||
2. [Tech Stack](#2-tech-stack)
|
||
3. [Repository Layout](#3-repository-layout)
|
||
4. [Environment & Configuration](#4-environment--configuration)
|
||
5. [Database Models](#5-database-models)
|
||
6. [Role & Permission Matrix](#6-role--permission-matrix)
|
||
7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory)
|
||
8. [Utility Modules](#8-utility-modules)
|
||
9. [Mobile API (Phase 7 / Phase A / Phase B / 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)
|
||
13. [Audit Trail](#13-audit-trail)
|
||
14. [PDF Export](#14-pdf-export)
|
||
15. [Scheduled Reports](#15-scheduled-reports)
|
||
16. [Rate Limiting](#16-rate-limiting)
|
||
17. [Alembic Migration Chain](#17-alembic-migration-chain)
|
||
18. [Frontend Conventions](#18-frontend-conventions)
|
||
19. [Infrastructure](#19-infrastructure)
|
||
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
||
21. [Change Philosophy](#21-change-philosophy)
|
||
|
||
---
|
||
|
||
## 1. Project Overview
|
||
|
||
**JQC (Janitorial Quality Control)** is a production-grade, full-stack web application that manages:
|
||
|
||
- Janitorial service contracts organised as **Contracts (Projects) → Facilities → Areas**
|
||
- **Inspection** execution against configurable templates with dynamic form builder
|
||
- **Issue** tracking with SLA enforcement, follower subscriptions, and verification workflow
|
||
- **Customer portal** with scoped facility visibility and invitation-based onboarding
|
||
- **Notification** system (in-app + email) driven by an admin-controlled matrix
|
||
- **Reports** — on-demand PDF/CSV scorecards and scheduled email digests
|
||
- **Audit trail** — immutable log of every create/update/delete action
|
||
- **Mobile API** — JWT-authenticated REST layer for the iPad native app
|
||
- **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B + C complete)
|
||
|
||
The application is actively deployed in production and maintained by a single developer/administrator.
|
||
|
||
---
|
||
|
||
## 2. Tech Stack
|
||
|
||
| Layer | Technology |
|
||
|---|---|
|
||
| Language | Python 3.11+ |
|
||
| Web framework | Flask (application factory pattern) |
|
||
| ORM | Flask-SQLAlchemy (SQLAlchemy 2.x) |
|
||
| Database | MySQL (via PyMySQL driver) |
|
||
| Auth (web) | Flask-Login + Flask-WTF CSRF |
|
||
| Auth (API) | JWT access tokens + opaque refresh tokens (PyJWT) |
|
||
| Rate limiting | Flask-Limiter (Redis-backed in production via `REDIS_URL`; falls back to in-process memory for dev) |
|
||
| Migrations | Flask-Migrate / Alembic |
|
||
| Email | Flask-Mail (SMTP, background threading) |
|
||
| PDF generation | ReportLab |
|
||
| Forms | WTForms + Flask-WTF |
|
||
| Templating | Jinja2 |
|
||
| Frontend | Bootstrap 5, Chart.js, vanilla JS |
|
||
| Server | Gunicorn (sync workers) behind Nginx |
|
||
| OS | Ubuntu Linux |
|
||
| **iPad app** | **SwiftUI + SwiftData, iOS 17+, Xcode 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()`) |
|
||
|
||
---
|
||
|
||
## 3. Repository Layout
|
||
|
||
```
|
||
lt_janitorial_quality_control/
|
||
├── app/
|
||
│ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager
|
||
│ ├── api/ # Mobile REST API
|
||
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
|
||
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*
|
||
│ │ ├── facilities.py # /api/v1/facilities/* (Phase A)
|
||
│ │ ├── templates.py # /api/v1/templates/* (Phase A)
|
||
│ │ ├── inspections.py # /api/v1/inspections/* (Phase B)
|
||
│ │ ├── issues.py # /api/v1/issues/* (Phase B + 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 — 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/
|
||
│ └── phase19_issue_mobile_photos.py ← HEAD
|
||
└── ...
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Environment & Configuration
|
||
|
||
### Required Environment Variables
|
||
|
||
| Variable | Notes |
|
||
|---|---|
|
||
| `SECRET_KEY` | Flask secret — no fallback; startup fails if absent |
|
||
| `DATABASE_URL` | e.g. `mysql+pymysql://user:pass@localhost/jqc` |
|
||
| `MAIL_SERVER` | SMTP hostname |
|
||
| `MAIL_USERNAME` | SMTP login |
|
||
| `MAIL_PASSWORD` | SMTP password |
|
||
| `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects flags |
|
||
| `APP_BASE_URL` | Full URL for email links |
|
||
| `MAIL_DEFAULT_SENDER` | From address |
|
||
| `DIGEST_SECRET` | Authenticates all cron endpoints |
|
||
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
||
|
||
### Email SSL Auto-Detection
|
||
|
||
```python
|
||
MAIL_USE_SSL = _mail_port == 465
|
||
MAIL_USE_TLS = not MAIL_USE_SSL
|
||
```
|
||
|
||
**Critical:** Never set both to `True` — Flask-Mail breaks silently.
|
||
|
||
### File Uploads
|
||
|
||
- `UPLOAD_FOLDER` = `app/static/uploads/`
|
||
- `MAX_CONTENT_LENGTH` = 50 MB
|
||
- Allowed: `png`, `jpg`, `jpeg`, `gif`
|
||
|
||
---
|
||
|
||
## 5. Database Models
|
||
|
||
### User
|
||
|
||
```
|
||
users: id, username (unique, indexed), full_name, email (unique, indexed),
|
||
password_hash, role (ENUM), created_at, active,
|
||
password_set, set_password_token (indexed), set_password_token_expires
|
||
```
|
||
|
||
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
|
||
|
||
**Key property:** `display_name` → `full_name.strip()` or falls back to `username`.
|
||
|
||
### Facility / Area
|
||
|
||
```
|
||
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK)
|
||
areas: id, facility_id (FK), name, area_type
|
||
```
|
||
|
||
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
|
||
|
||
### Project / CustomerAssignment
|
||
|
||
```
|
||
projects: id, name, description, project_manager_id, active, created_at
|
||
customer_assignments: id, user_id, project_id, facility_id (nullable)
|
||
UniqueConstraint(user_id, project_id, facility_id)
|
||
```
|
||
|
||
### Inspection
|
||
|
||
```
|
||
inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date,
|
||
overall_score, status (in_progress/completed/flagged), notes, form_data (JSON),
|
||
completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note,
|
||
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
|
||
```
|
||
|
||
**`mobile_local_id`:** UUID string generated on the iPad. Used for idempotency — if a submission arrives twice (network retry), the server returns the existing record without creating a duplicate. Set `NULL` for all web-created inspections.
|
||
|
||
**Score rule:** Items with `score = 0` mean "unanswered" — excluded from calculation entirely.
|
||
|
||
### Issue
|
||
|
||
```
|
||
issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity (low/medium/high/critical),
|
||
description, photo_path 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
|
||
```
|
||
|
||
**Photo columns — three distinct fields with different semantics:**
|
||
|
||
| Column | Type | Populated by | Displayed as |
|
||
|---|---|---|---|
|
||
| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) |
|
||
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos | "Photo Evidence" (additional) |
|
||
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
|
||
|
||
**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details".
|
||
|
||
**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue. Nullable for backward compatibility. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet.
|
||
|
||
### Notification / NotificationPreference
|
||
|
||
```
|
||
notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
|
||
inspection_id, event_type VARCHAR(50) NULL, digest_pending
|
||
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
||
```
|
||
|
||
### NotificationMatrix
|
||
|
||
```
|
||
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||
UniqueConstraint(event_type, role_key)
|
||
```
|
||
|
||
### AuditLog
|
||
|
||
```
|
||
audit_logs: id, user_id (nullable), username (snapshot), user_role (snapshot),
|
||
action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address
|
||
```
|
||
|
||
### RefreshToken / DeviceToken
|
||
|
||
```
|
||
api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name,
|
||
created_at, expires_at, revoked
|
||
api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at
|
||
UniqueConstraint(user_id, device_id)
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Role & Permission Matrix
|
||
|
||
| Area | admin | director | project_manager | inspector | customer |
|
||
|---|---|---|---|---|---|
|
||
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
|
||
| Users | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
||
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Facilities | ✅ | ✅ | ✅ | read | scoped |
|
||
| Contracts | ✅ | ✅ | ✅ | read | scoped |
|
||
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
|
||
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | read |
|
||
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Reports | ✅ | ✅ | ✅ | ✅ | scoped |
|
||
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||
| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
||
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||
|
||
### Decorator Map
|
||
|
||
```python
|
||
@admin_required # role == 'admin' only
|
||
@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes
|
||
@project_manager_required # role in ('admin', 'director', 'project_manager')
|
||
@customer_required # role == 'customer' only
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Blueprint Prefixes & Route Inventory
|
||
|
||
| Blueprint | Prefix | Notable routes |
|
||
|---|---|---|
|
||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||
| `facilities` | `/facilities` | CRUD + area management |
|
||
| `projects` | `/projects` | CRUD + customer assignment management |
|
||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign |
|
||
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
|
||
| `audit` | `/audit` | list (admin only), view, purge |
|
||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export |
|
||
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger |
|
||
| `api` | `/api/v1` | parent blueprint |
|
||
| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` |
|
||
| `api_facilities` | `/api/v1` | `/facilities`, `/facilities/<id>/areas` |
|
||
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
|
||
| `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/<id>` |
|
||
| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos` ← Phase 19 |
|
||
| `api_photos` | `/api/v1` | `POST /photos/upload` |
|
||
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
|
||
|
||
---
|
||
|
||
## 8. Utility Modules
|
||
|
||
### `time_utils.py`
|
||
`now_eastern()` — always use this, never `datetime.utcnow()`.
|
||
|
||
### `audit.py`
|
||
`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session.
|
||
|
||
### `scope.py`
|
||
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff.
|
||
|
||
### `forms.py`
|
||
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role.
|
||
|
||
### `notifications.py`
|
||
`notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread. `notify()` stores `event_type` on the `Notification` record (phase17+). `flag_followup` route calls `notify()` for the original inspector.
|
||
|
||
### `sla.py`
|
||
`sla_status(issue)` → `'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
|
||
|
||
### `pdf_export.py`
|
||
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
|
||
|
||
---
|
||
|
||
## 9. Mobile API (Phase 7 / Phase A / Phase B / Phase C)
|
||
|
||
### CSRF Exemption Pattern — Critical
|
||
|
||
**`csrf.exempt(api_bp)` does NOT cascade to sub-blueprints.** Each child blueprint must be exempted individually in `app/__init__.py`. The new `api_issues` blueprint (including its `PATCH /issues/<id>/photos` route) inherits the exemption already applied to `_api_issues_bp`. **Every new blueprint must add its own `csrf.exempt()` line before `register_api(app)`.**
|
||
|
||
### Auth Flow
|
||
1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex)
|
||
2. Bearer token on every request
|
||
3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued)
|
||
4. `POST /api/v1/auth/logout` → revokes refresh token
|
||
|
||
### Phase A Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user |
|
||
| `GET /api/v1/facilities/<id>/areas` | jwt_required | Areas for a facility |
|
||
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) |
|
||
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema |
|
||
|
||
### 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`; 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
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `GET /api/v1/inspections` | jwt_required | Inspector's own inspection history (paginated) |
|
||
| `GET /api/v1/issues` | jwt_required | Issues assigned to OR reported by current user (inspectors); all non-resolved (admin/director/PM) |
|
||
| `GET /api/v1/issues/<id>` | jwt_required | Single issue detail |
|
||
| `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status |
|
||
| `GET /api/v1/notifications` | jwt_required | Unread notifications; accepts `?since=<ISO 8601>` |
|
||
| `PATCH /api/v1/notifications/mark-read` | jwt_required | Mark list of notification IDs as read |
|
||
|
||
### Phase 19 Endpoint
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `PATCH /api/v1/issues/<id>/photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. Access: inspector must be `assigned_to` or `reported_by`. |
|
||
|
||
### Issue API — `_issue_payload()` fields
|
||
|
||
```python
|
||
{
|
||
'id', 'status', 'severity', 'description', 'assigned_to',
|
||
'facility_id', 'facility_name', 'reported_at', 'resolved_at',
|
||
'mobile_local_id',
|
||
'photo_path', # primary evidence photo (first iPad photo or web upload)
|
||
'mobile_photo_paths', # extra evidence photos from iPad (list) ← Phase 19
|
||
'result_photos', # resolution photos added via web form (list)
|
||
}
|
||
```
|
||
|
||
**iOS reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. It does NOT read `result_photos` — those are web-only resolution photos.**
|
||
|
||
### Issue API Scope Rules
|
||
|
||
- **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=`.
|
||
- `GET /issues/<id>` and `PATCH /issues/<id>/status` and `PATCH /issues/<id>/photos` all enforce the same combined inspector check.
|
||
|
||
### Photo Upload Flow (multi-photo issues)
|
||
|
||
```
|
||
1. iPad calls POST /api/v1/photos/upload × N → gets N server_path strings
|
||
2. iPad calls POST /api/v1/issues → sends photo_path = paths[0]
|
||
result_photos = paths[1:] (stored in mobile_photo_paths)
|
||
3. iPad calls PATCH /api/v1/issues/<id>/photos → sends result_photos = paths[1:]
|
||
(PATCH is belt-and-suspenders for race safety)
|
||
```
|
||
|
||
Web template shows `photo_path` + `mobile_photo_paths` together under **"Photo Evidence"**. `result_photos` (resolution photos from web form) appears under **"Resolution Details"**.
|
||
|
||
### Facility deduplication
|
||
|
||
`pullReferenceData()` deduplicates the `/api/v1/facilities` response by `id` before upserting. The server may return the same facility ID more than once (one row per contract assignment). Without deduplication, the same building appears twice in every picker. The dedup uses a `seenFacilityIds = Set<Int>()` filter on the iOS side AND the upsert map (`facilityMap`) on the server side.
|
||
|
||
### Idempotency Pattern
|
||
|
||
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. Returns `float` 0–100 or `None` if no scoreable fields.
|
||
|
||
---
|
||
|
||
## 10. iPad Native App
|
||
|
||
See the iOS app's own `CLAUDE.md` for full details. Key integration points:
|
||
|
||
- 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`.
|
||
|
||
---
|
||
|
||
## 11. Notification System
|
||
|
||
### Event Constants (`app/models/notification.py`)
|
||
```
|
||
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
|
||
EVENT_ISSUE_STATUS = 'issue_status'
|
||
EVENT_ISSUE_COMMENT = 'issue_comment'
|
||
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
|
||
EVENT_INSPECTION_DONE = 'inspection_completed'
|
||
EVENT_SLA_ALERT = 'sla_alert'
|
||
EVENT_ISSUE_FLAGGED = 'issue_flagged'
|
||
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
|
||
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
||
```
|
||
|
||
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
||
|
||
| Endpoint | Purpose | Schedule |
|
||
|---|---|---|
|
||
| `POST /notifications/send-digest` | Digest email delivery | `0 7 * * *` |
|
||
| `POST /notifications/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` |
|
||
| `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` |
|
||
|
||
---
|
||
|
||
## 12. SLA Engine
|
||
|
||
| Severity | Window | At-Risk |
|
||
|---|---|---|
|
||
| critical | 4h | 3h |
|
||
| high | 24h | 18h |
|
||
| medium | 72h | 54h |
|
||
| low | 168h | 126h |
|
||
|
||
`issue.sla_notified` prevents duplicate cron notifications.
|
||
|
||
---
|
||
|
||
## 13. Audit Trail
|
||
|
||
- Admin-only at `/audit/` — director is excluded
|
||
- Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT`
|
||
- Mobile API routes call `log_action()` for all create/update operations
|
||
- Immutable — never updated or deleted through the application
|
||
|
||
---
|
||
|
||
## 14. PDF Export
|
||
|
||
ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF.
|
||
|
||
---
|
||
|
||
## 15. Scheduled Reports
|
||
|
||
Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`.
|
||
Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
|
||
|
||
---
|
||
|
||
## 16. Rate Limiting
|
||
|
||
```python
|
||
limiter = Limiter(
|
||
key_func = get_remote_address,
|
||
default_limits = [],
|
||
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
|
||
)
|
||
```
|
||
|
||
**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`.
|
||
|
||
---
|
||
|
||
## 17. Alembic Migration Chain
|
||
|
||
```
|
||
phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix
|
||
→ phase9_user_full_name → phase10_customer_password_setup → phase11_director_role
|
||
→ phase12_performance_indexes → phase_b_mobile_local_id
|
||
→ phase13_issue_facility → phase14_facility_created_at
|
||
→ phase15_audit_log_indexes → phase16_notifications_columns
|
||
→ phase17_notification_event_type
|
||
→ phase18_issue_reported_by
|
||
→ phase19_issue_mobile_photos ← HEAD
|
||
```
|
||
|
||
### phase19_issue_mobile_photos
|
||
|
||
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.
|
||
|
||
**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
|
||
-- 1. Expand
|
||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
|
||
-- 2. Migrate
|
||
UPDATE users SET role = 'director' WHERE role = 'supervisor';
|
||
-- 3. Contract
|
||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
|
||
```
|
||
|
||
### MySQL Compatibility Rules
|
||
|
||
- **`CREATE INDEX IF NOT EXISTS`** — not supported on MySQL < 8.0.12. Always use `INFORMATION_SCHEMA.STATISTICS` check first.
|
||
- **`batch_alter_table`** — SQLite-only workaround; do not use for MySQL migrations.
|
||
- **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
|
||
# WRONG
|
||
Model.query.get(id)
|
||
|
||
# CORRECT
|
||
obj = db.session.get(Model, id)
|
||
if obj is None: abort(404)
|
||
```
|
||
|
||
---
|
||
|
||
## 18. Frontend Conventions
|
||
|
||
### Active Nav Tab
|
||
Detected via `request.endpoint.startswith('<blueprint>.')` in each nav `<a>` tag.
|
||
|
||
### Display Names
|
||
Always use `user.display_name` in templates — never `.username` for display purposes.
|
||
|
||
### Status Label Map
|
||
| DB value | Displayed as |
|
||
|---|---|
|
||
| `completed` | **Submitted** |
|
||
| `in_progress` | In Progress |
|
||
| `flagged` | Flagged |
|
||
| `open` | Open |
|
||
| `resolved` | Resolved |
|
||
| `pending_verification` | Pending Verification |
|
||
|
||
### Forms
|
||
- Flask-WTF CSRF auto-applied to all web forms
|
||
- **Never nest `<form>` tags** — browsers silently discard inner forms
|
||
|
||
### Real-Time
|
||
**SSE banned.** All "live" updates use polling.
|
||
|
||
### Issue Photo Evidence Display (view.html)
|
||
|
||
`view.html` shows `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.
|
||
|
||
### Inspection Execute Page — UX Patterns
|
||
|
||
- **Photo upload-on-select**: `uploadPhotoField(input)` fires immediately on `<input type="file">` change. XHR to `POST /<id>/upload-photo`. On success, the server path is written to `<input type="hidden" id="field_<fid>_server_path">` and a `<img id="thumb_<fid>">` is shown.
|
||
- **Flag-issue as offcanvas**: `#flagIssuePanel` Bootstrap offcanvas contains the flag-issue form. On submit, `saveDraft()` fires first, then the form is sent via `fetch()` FormData, then the page reloads. Never navigates away — photos are never lost.
|
||
- **Auto-save draft**: `setInterval(autoSave, 60000)` calls the save-draft endpoint every 60 s. `#autoSaveStatus` in the footer shows the last-saved timestamp.
|
||
- **Progress indicator**: Counts answered non-zero rating fields vs. total; updates `#progressLabel` in the footer on every change.
|
||
- **Scroll restore**: `window.scrollY` saved to `sessionStorage` on `beforeunload`; restored on `load`.
|
||
|
||
---
|
||
|
||
## 19. Infrastructure
|
||
|
||
### Gunicorn
|
||
```python
|
||
bind = "127.0.0.1:8000"
|
||
workers = multiprocessing.cpu_count() * 2 + 1
|
||
worker_class = "sync"
|
||
timeout = 30
|
||
```
|
||
|
||
### Application Logging
|
||
- `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB)
|
||
- `StreamHandler` → stdout (journalctl)
|
||
|
||
### Nginx
|
||
- `client_max_body_size 50M`
|
||
- Passes `X-Forwarded-For`
|
||
|
||
### Recommended Cron Schedule
|
||
```bash
|
||
0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \
|
||
-d "token=SECRET&frequency=daily"
|
||
*/30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \
|
||
-d "token=SECRET"
|
||
0 3 * * * curl -s -X POST https://your-domain.com/notifications/cleanup-tokens \
|
||
-d "token=SECRET"
|
||
0 8 * * * curl -s -X POST https://your-domain.com/scheduled-reports/run \
|
||
-d "secret=SECRET"
|
||
```
|
||
|
||
---
|
||
|
||
## 20. Known Constraints & Hard Rules
|
||
|
||
| # | Rule | Rationale |
|
||
|---|---|---|
|
||
| 1 | **No SSE** | Exhausted Gunicorn sync worker pool |
|
||
| 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs |
|
||
| 3 | **3-step MySQL ENUM changes** | Skipping causes data loss |
|
||
| 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail |
|
||
| 5 | **`csrf.exempt()` on each child blueprint individually** | `csrf.exempt(api_bp)` does NOT cascade; Flask-WTF checks leaf blueprint object only |
|
||
| 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators |
|
||
| 7 | **Score 0 = unanswered** | Excluded from calculation — not the same as scoring zero |
|
||
| 8 | **12-column grid in PDF** | Must not collapse in print/PDF |
|
||
| 9 | **No nested `<form>` tags** | Browsers silently discard inner forms |
|
||
| 10 | **`log_action()` after `db.session.commit()`** | Entity ID must exist before audit capture |
|
||
| 11 | **`db.session.get(Model, id)` not `Model.query.get(id)`** | SQLAlchemy 2.x deprecation |
|
||
| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement |
|
||
| 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 |
|
||
| 14 | **Email in background thread** | Never block HTTP response |
|
||
| 15 | **Open-redirect guards** | `safe_redirect_url()` in `app/utils/decorators.py` |
|
||
| 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.12** | Use `INFORMATION_SCHEMA.STATISTICS` check |
|
||
| 17 | **`batch_alter_table` is SQLite-only** | Use direct `ALTER TABLE` for MySQL migrations |
|
||
| 18 | **Set `REDIS_URL` in production** | `memory://` is per-process; Gunicorn needs Redis for accurate shared counters |
|
||
| 19 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged |
|
||
| 20 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only |
|
||
| 21 | **`mobile_local_id` idempotency on all mobile write endpoints** | Network retries must not create duplicate records |
|
||
| 22 | **Photo upload before inspection/issue submission** | Server path must be known before the parent record is created |
|
||
| 23 | **Migration deploy before new `app/__init__.py`** | New init imports models referencing new columns; columns must exist first |
|
||
| 24–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 | **Inspector issue scope: assigned OR reported — web and API must match** | `issues.index()`, `issues.view()`, and all API issue endpoints (`GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status`, `PATCH /issues/<id>/photos`) 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/<id>/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<Int>()` |
|
||
| 47 | **Magic-byte validation in `_save_photo()`** | Added post-phase-19. Reads 8 bytes before saving; rejects files that do not begin with a known image magic (`\xff\xd8\xff`, `\x89PNG`, `GIF87a`, `GIF89a`). Prevents MIME-type spoofing via extension-only checks. |
|
||
| 48 | **`upload_photo_ajax` endpoint on inspections blueprint** | `POST /<inspection_id>/upload-photo` with `@limiter.limit("30 per minute")`. Triggers on file selection (not form submit) so photos survive AJAX draft-save and page navigation. Stores in `inspection_photos/` subfolder; returns `{ok, path}`. |
|
||
| 49 | **Template schema snapshotted at submit time** | `execute()` POST stores `form_fields` list as `_template_schema` inside `inspection.notes` JSON. `view()` prefers this snapshot over the live template so historical inspection views remain correct if the template changes later. |
|
||
| 50 | **`mobile_local_id` UUID format validation on write endpoints** | `POST /api/v1/inspections` and `POST /api/v1/issues` validate `mobile_local_id` against `_UUID_RE` regex. Rejects non-UUID strings with HTTP 400. Prevents garbage values from being stored as idempotency keys. |
|
||
| 51 | **Security response headers via `@app.after_request`** | Added `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, and a `Content-Security-Policy` (CDN allowlist + `unsafe-inline`). Uses `setdefault` so API responses can override if needed. |
|
||
| 52 | **Inspection `execute.html` offline-resilient photo flow** | Photos are uploaded immediately on file selection via `uploadPhotoField()` (XHR to `upload_photo_ajax`). Server path is stored in `<input type="hidden" id="field_<fid>_server_path">`. AJAX draft-save and flag-issue submission read these hidden fields so photos are never lost on navigation. |
|
||
| 53 | **Flag-issue panel is an offcanvas — not a page navigation** | Converted from a navigate-away flow to a Bootstrap offcanvas. Draft is saved first via `saveDraft()`, then the flag-issue form is submitted via `fetch()` FormData, then the page reloads. Eliminates the entire class of "photos lost on navigation" bugs. |
|
||
| 54 | **Bulk issue verification via `POST /issues/bulk-verify`** | `@supervisor_required`. Accepts `issue_ids` list from form. Skips issues not in `resolved` or `pending_verification` state. Calls `log_action()` after `db.session.commit()` per rule 10. |
|
||
| 55 | **Scheduled "issues" report groups by facility with SLA status** | `_build_report_data()` now produces `issues_by_facility` (list of `(facility_name, [(issue, sla), ...])`) and `sla_breached`/`sla_at_risk` counts alongside the flat `issues` list. CSV builder uses `resolved_facility` (not `area.facility`) to avoid crash when `area_id` is None. |
|
||
| 56 | **Customer role: `POST` to `issues.view` returns 403** | The `view()` route checks `request.method == 'POST'` inside the customer scope block and calls `abort(403)`. Customers have read-only access; the template already hides the update form, but server-side enforcement is required against crafted requests. |
|
||
|
||
---
|
||
|
||
## 21. Change Philosophy
|
||
|
||
1. **Surgical, additive patches** — smallest possible change to achieve the goal
|
||
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise
|
||
3. **Never remove existing functionality** unless explicitly directed
|
||
4. **Log all create/update/delete actions** via `log_action()`
|
||
5. **Migration existence checks** — all migrations safe to re-run
|
||
6. **Full file contents for 1–3 file changes**; deployment map for larger changesets
|
||
7. **Explicit deploy instructions** — migration steps separated from code steps
|
||
8. **Root cause analysis** on errors — never apply temporary workarounds |