845 lines
42 KiB
Markdown
845 lines
42 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 18 complete — reported_by on issues, issue_flagged notification prefs, dashboard follow-up query fix, customer join refactor)
|
||
|
||
---
|
||
|
||
## 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)](#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)
|
||
|
||
---
|
||
|
||
## 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 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()`) |
|
||
|
||
---
|
||
|
||
## 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)
|
||
│ │ ├── photos.py # /api/v1/photos/upload (Phase B)
|
||
│ │ ├── decorators.py # @jwt_required
|
||
│ │ ├── errors.py # JSON error helpers + error handler registration
|
||
│ │ └── jwt_utils.py # generate_access_token()
|
||
│ ├── models/
|
||
│ │ ├── inspection.py # Inspection now has mobile_local_id column (Phase B)
|
||
│ │ ├── issue.py # Issue now has mobile_local_id column (Phase B)
|
||
│ │ └── ... # (all other models unchanged)
|
||
│ ├── routes/ # (unchanged from Phase 12)
|
||
│ ├── static/
|
||
│ ├── templates/
|
||
│ └── utils/
|
||
├── migrations/
|
||
│ └── versions/
|
||
│ ├── phase1_projects_roles.py
|
||
│ ├── phase6_features.py
|
||
│ ├── phase7_mobile_api.py
|
||
│ ├── phase8_notification_matrix.py
|
||
│ ├── phase9_user_full_name.py
|
||
│ ├── phase10_customer_password_setup.py
|
||
│ ├── phase11_director_role.py
|
||
│ ├── phase12_performance_indexes.py
|
||
│ └── phase_b_mobile_local_id.py ← HEAD
|
||
├── JanitorialQC/ # Xcode iOS project root
|
||
│ ├── JanitorialQC.xcodeproj
|
||
│ └── JanitorialQC/
|
||
│ ├── JQCApp.swift # @main — SwiftData container, env objects
|
||
│ ├── ContentView.swift # Auth gate: LoginView ↔ DashboardView
|
||
│ ├── Auth/
|
||
│ │ ├── AuthManager.swift # Login/logout/restore session, Keychain persistence
|
||
│ │ └── KeychainHelper.swift # Security.framework wrapper
|
||
│ ├── API/
|
||
│ │ ├── APIClient.swift # URLSession + JWT inject + 401 retry + photo upload
|
||
│ │ └── APIModels.swift # Codable response DTOs
|
||
│ ├── Sync/
|
||
│ │ └── SyncManager.swift # NWPathMonitor + outbox queue processor
|
||
│ ├── Models/ # SwiftData local models
|
||
│ │ ├── LocalFacility.swift
|
||
│ │ ├── LocalArea.swift
|
||
│ │ ├── LocalTemplate.swift
|
||
│ │ ├── LocalInspection.swift
|
||
│ │ ├── LocalIssue.swift
|
||
│ │ ├── PendingPhoto.swift
|
||
│ │ └── SyncQueueEntry.swift
|
||
│ ├── Views/
|
||
│ │ ├── Auth/
|
||
│ │ │ └── LoginView.swift
|
||
│ │ ├── Dashboard/
|
||
│ │ │ └── DashboardView.swift # Sidebar + all detail views
|
||
│ │ └── Inspection/
|
||
│ │ ├── StartInspectionView.swift
|
||
│ │ ├── ExecuteInspectionView.swift
|
||
│ │ ├── FlagIssueView.swift
|
||
│ │ └── FormRenderer/
|
||
│ │ └── FormFieldView.swift # All field type renderers
|
||
│ └── Utils/
|
||
│ └── Constants.swift # baseURL, Keychain key strings
|
||
├── config.py
|
||
├── gunicorn_config.py
|
||
├── requirements.txt
|
||
├── run.py
|
||
└── wsgi.py
|
||
```
|
||
|
||
---
|
||
|
||
## 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, 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),
|
||
verified_by, verified_at, verification_note, sla_notified,
|
||
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
|
||
```
|
||
|
||
**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue — on the web (`current_user.id`) and via the mobile API (`g.api_user.id`). Nullable for backward compatibility; pre-phase18 rows have `NULL`. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet.
|
||
|
||
### 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
|
||
```
|
||
|
||
**`event_type`:** Added in phase17. Stored by `notify()` and returned by `GET /api/v1/notifications` so the iPad can categorise alerts. `NULL` for notifications created before the migration.
|
||
|
||
### NotificationMatrix
|
||
|
||
```
|
||
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 |
|
||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||
| `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`, `/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` |
|
||
| `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+) so the mobile API can return it to the iPad for categorisation. `flag_followup` route calls `notify()` for the original inspector so they receive a follow-up request notification on the iPad.
|
||
|
||
### `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.** Flask-WTF's `_is_exempt()` checks the leaf blueprint object. Each child blueprint must be exempted individually in `app/__init__.py`:
|
||
|
||
```python
|
||
from app.api import register_api, api_bp
|
||
from app.api.auth import bp as _api_auth_bp
|
||
from app.api.facilities import bp as _api_facilities_bp
|
||
from app.api.templates import bp as _api_templates_bp
|
||
from app.api.inspections import bp as _api_inspections_bp
|
||
from app.api.issues import bp as _api_issues_bp
|
||
from app.api.photos import bp as _api_photos_bp
|
||
from app.api.notifications import bp as _api_notifications_bp
|
||
csrf.exempt(_api_auth_bp)
|
||
csrf.exempt(_api_facilities_bp)
|
||
csrf.exempt(_api_templates_bp)
|
||
csrf.exempt(_api_inspections_bp)
|
||
csrf.exempt(_api_issues_bp)
|
||
csrf.exempt(_api_photos_bp)
|
||
csrf.exempt(_api_notifications_bp)
|
||
register_api(app)
|
||
```
|
||
|
||
**Every new Phase D+ blueprint must add its own `csrf.exempt()` line here before `register_api(app)`.** Failing to do so produces a `"The CSRF token is missing."` error on all POST requests to that blueprint.
|
||
|
||
### 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
|
||
|
||
### Rate Limits
|
||
| Endpoint | Limit |
|
||
|---|---|
|
||
| `POST /api/v1/auth/login` | 10/min, 3/sec |
|
||
| `POST /api/v1/auth/refresh` | 30/min, 5/sec |
|
||
|
||
### Phase A Endpoints
|
||
|
||
| Endpoint | Auth | Description |
|
||
|---|---|---|
|
||
| `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` |
|
||
|
||
### 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 current user (inspectors); all non-resolved (admin/director/PM) |
|
||
| `GET /api/v1/issues/<id>` | jwt_required | Single issue detail — inspectors scoped to assigned only |
|
||
| `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status — inspectors scoped to assigned only |
|
||
| `GET /api/v1/notifications` | jwt_required | Unread notifications for current user; accepts `?since=<ISO 8601>` |
|
||
| `PATCH /api/v1/notifications/mark-read` | jwt_required | Mark list of notification IDs as read |
|
||
|
||
### Issue API Scope Rules
|
||
|
||
- **Inspector:** `GET /issues` returns issues where `assigned_to == current_user.id` **OR** `reported_by == current_user.id`. This ensures issues the inspector created on the iPad appear even before a director assigns them. `GET /issues/<id>` and `PATCH /issues/<id>/status` enforce the same combined check.
|
||
- **Admin / Director / Project Manager:** `GET /issues` returns all non-resolved issues (default) or filtered by `?status=`.
|
||
- `_issue_payload()` returns: `id`, `status`, `severity`, `description`, `assigned_to`, `facility_id`, `facility_name`, `reported_at`, `resolved_at`, `mobile_local_id`, `photo_path`, `result_photos`.
|
||
|
||
### Notification API — OperationalError Safety
|
||
|
||
`GET /api/v1/notifications` wraps the ORM query in `try/except sqlalchemy.exc.OperationalError`. If the `event_type` column does not yet exist (phase17 migration not run), it falls back to a raw-SQL query that omits the column and returns `"event_type": null`. This keeps the endpoint functional before and after the migration.
|
||
|
||
### Idempotency Pattern
|
||
|
||
All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt:
|
||
|
||
```python
|
||
existing = Model.query.filter_by(mobile_local_id=mobile_local_id).first()
|
||
if existing:
|
||
return api_ok({'id': existing.id, 'duplicate': True})
|
||
```
|
||
|
||
This protects against double-submission when the network fails after the server commits but before the device receives the response. Web-created records have `mobile_local_id = NULL`.
|
||
|
||
### Photo Upload Flow
|
||
|
||
Photos are uploaded **before** the inspection or issue is submitted:
|
||
1. iPad calls `POST /api/v1/photos/upload` with multipart image
|
||
2. Server saves to `app/static/uploads/inspection_photos/` or `issue_photos/`
|
||
3. Returns `{ "server_path": "uploads/inspection_photos/uuid.jpg" }`
|
||
4. iPad includes `server_path` in the subsequent inspection/issue POST
|
||
|
||
### 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. 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"`.
|
||
- **`pullAssignedIssues()`:** Fetches `GET /api/v1/issues` and upserts into SwiftData keyed by `serverId`. Records pulled from server carry `syncStatus = "synced"` and `inspectionLocalId = ""` so `processIssueQueue` never re-submits them. **Deletion pass runs after upsert** — records with `syncStatus == "synced"` AND `inspectionLocalId == ""` whose `serverId` is absent from the server response are deleted. This removes issues that were reassigned to another inspector. The empty-response case is not short-circuited, so unassignment is always handled.
|
||
- **Notification polling:** `pollNotifications()` is called at the end of every `triggerSync()` and also on a 60-second `Task.sleep` loop started by `startPollTask()`. Uses `lastNotificationFetch` as a cursor (`?since=` param) so only new notifications are fetched. Marks fetched IDs read on server after local delivery.
|
||
- **`Task.sleep` not `Timer.scheduledTimer`:** `Timer.scheduledTimer` requires `RunLoop.main` to be ticking; inside a Swift Concurrency `Task { @MainActor }` block `RunLoop.current` is not `RunLoop.main` and the timer fires never. Always use `Task.sleep` for periodic work in SyncManager.
|
||
|
||
### APIClient Key Behaviours
|
||
|
||
- **401 auto-retry:** On a 401 response, `refreshAccessToken()` is called once and the original request is retried. If refresh fails, `APIError.notAuthenticated` is thrown.
|
||
- **Keychain token storage:** `kSecAttrAccessibleAfterFirstUnlock` — tokens survive device reboot, accessible for background sync.
|
||
- **Photo upload:** Multipart `form-data` built manually (no third-party library). Boundary is a UUID string.
|
||
|
||
### FormFieldView — Supported Field Types
|
||
|
||
All types from the web app's `INPUT_FIELD_TYPES` set are rendered:
|
||
|
||
| Type | SwiftUI renderer |
|
||
|---|---|
|
||
| `text`, `email` | `TextField` |
|
||
| `textarea` | `TextEditor` |
|
||
| `number` | `TextField` + `.decimalPad` |
|
||
| `date` | `DatePicker` |
|
||
| `checkbox` | `Toggle` |
|
||
| `checkbox_group` | Custom multi-select buttons |
|
||
| `radio` | Custom radio buttons |
|
||
| `select` | `Picker(.menu)` |
|
||
| `rating` | Custom star rating (tap same star to clear) |
|
||
| `pass_fail` | Two-button Pass/Fail control |
|
||
| `signature` | `PKCanvasView` (PencilKit) |
|
||
| `image` | `UIImagePickerController` sheet → local file save |
|
||
| `table` | `Grid` of `TextField` |
|
||
| `section`, `label` | Display-only `Text` |
|
||
|
||
### Known iOS-Specific Constraints
|
||
|
||
| # | Constraint | Rationale |
|
||
|---|---|---|
|
||
| 1 | **`import Combine` required for `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors |
|
||
| 2 | **`NavigationSplitView` — no `selection:` binding** | `init(selection:content:)` unavailable on iPadOS 17; use `List` with manual `Button` + `@State var selectedTab` |
|
||
| 3 | **`#Predicate` — no string literal comparisons across model boundaries** | SwiftData macro type-inference bug; fetch all + filter in Swift instead |
|
||
| 4 | **`async let` — Swift 6 actor-isolation warnings on Decodable** | Use sequential `await` calls for reference data fetches |
|
||
| 5 | **PencilKit requires framework linkage** | Add `PencilKit.framework` under Target → Frameworks, Libraries, and Embedded Content |
|
||
| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls |
|
||
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for background sync** | Tokens must be readable when the app is woken by BGTaskScheduler |
|
||
|
||
---
|
||
|
||
## 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' ← added; must be in ALL_EVENT_TYPES
|
||
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
|
||
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
||
```
|
||
|
||
**`ALL_EVENT_TYPES`** is the authoritative dict for the preferences UI. Every `event_type` string passed to `notify()` or `notify_by_matrix()` must have a matching entry here — missing entries cause that event to be invisible in the preferences form.
|
||
|
||
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
||
|
||
| Endpoint | Purpose | Schedule |
|
||
|---|---|---|
|
||
| `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`. Counters shared across all Gunicorn workers.
|
||
|
||
---
|
||
|
||
## 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 ← 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.
|
||
|
||
### phase13_issue_facility
|
||
|
||
Adds nullable `facility_id` FK column to `issues`; back-fills from `areas.facility_id`; makes `area_id` nullable.
|
||
Uses direct `ALTER TABLE` + `INFORMATION_SCHEMA` checks — safe to re-run.
|
||
|
||
### phase14_facility_created_at
|
||
|
||
Adds nullable `created_at` `DATETIME` column to `facilities`.
|
||
Uses direct `ALTER TABLE` + `INFORMATION_SCHEMA` check — safe to re-run.
|
||
|
||
### phase15_audit_log_indexes
|
||
|
||
Adds individual indexes on `audit_logs.action` and `audit_logs.entity_type`.
|
||
Uses `INFORMATION_SCHEMA.STATISTICS` existence checks — safe to re-run.
|
||
|
||
### phase16_notifications_columns
|
||
|
||
Ensures `digest_pending TINYINT NOT NULL DEFAULT 0` and `inspection_id INT NULL FK` exist on the `notifications` table. Both columns are defined in the model but were absent from any prior migration because the `notifications` table predates the chain. Uses `INFORMATION_SCHEMA` existence checks — safe to re-run.
|
||
|
||
### phase17_notification_event_type
|
||
|
||
Adds `event_type VARCHAR(50) NULL` to the `notifications` table. Required for `GET /api/v1/notifications` to return event type to the iPad so it can categorise alerts. The API endpoint catches `OperationalError` and falls back to raw SQL before this migration runs. Uses `INFORMATION_SCHEMA` existence check — safe to re-run.
|
||
|
||
### phase18_issue_reported_by
|
||
|
||
Adds `reported_by INT NULL FK → users.id ON DELETE SET NULL` to the `issues` table. Allows the mobile API to return issues the inspector created (but hasn't been assigned) alongside their assigned issues. Nullable — pre-phase18 rows have `NULL` and surface only via the `assigned_to` path. Uses `INFORMATION_SCHEMA` existence and constraint checks — safe to re-run.
|
||
|
||
### 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. 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)
|
||
|
||
# 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.
|
||
|
||
---
|
||
|
||
## 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)
|
||
- Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message`
|
||
|
||
### 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` — the single canonical utility, imported by both `auth.py` and `customers.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 | **`import Combine` required in iOS files using `@Published`** | Swift 5.9+ does not auto-import Combine |
|
||
| 25 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 |
|
||
| 26 | **SwiftData `#Predicate` — fetch all + filter in Swift for string comparisons** | Macro type-inference bug with string literals across model type boundaries |
|
||
| 27 | **Sequential `await` for reference data fetches in SyncManager** | `async let` causes Swift 6 actor-isolation warnings on Decodable structs |
|
||
| 28 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks |
|
||
| 29 | **`get_customer_scope()` uses bulk project query** | Replaces per-assignment loop |
|
||
| 30 | **CSV exports always call `log_action(ACTION_EXPORT, ...)`** | Data exports are compliance-relevant audit events |
|
||
| 31 | **Do NOT add an explicit `Issue.area` relationship** | `Area.issues` declares `backref='area'`, supplying `Issue.area` automatically. A second declaration on `Issue` raises `ConflictingBackreferences` at startup. The dependency is documented here; do not "fix" it by adding an explicit relationship. |
|
||
| 32 | **Do not sync an issue when its parent `LocalInspection.syncStatus == "failed"`** | Submitting without `inspection_id` creates orphaned server records; mark issue `"failed"` instead |
|
||
| 33 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes; use `"\u2014"` not `'—'` inside `f'...'` |
|
||
| 34 | **`computeScore` field ID must be cast explicitly: `String` → `as? String`, `Int` → `as? Int` then `String(n)`** | `Optional.map` on `Any?` returns `Optional(value)` not `value`; the old guard-let produced `"Optional(5)"` as the lookup key, so all integer-ID field scores were silently 0 |
|
||
| 35 | **Strip `local://` photo paths from `formData` before `submitInspection`** | A failed photo upload leaves `"local://..."` in formData; `JSONSerialization` drops non-serialisable values silently, which is worse than an empty string on the server |
|
||
| 36 | **`notify()` must always receive `event_type`** | Without it the mobile API returns `null` for event type and the iPad cannot categorise the alert banner |
|
||
| 37 | **`flag_followup` calls `notify()` for the original inspector** | Without this, the inspector receives no follow-up request notification on any channel |
|
||
| 38 | **`GET /api/v1/notifications` catches `OperationalError`** | If phase17 migration hasn't run, the ORM query fails at the SQL layer because `event_type` is in the SELECT but not in the DB; `getattr()` does NOT protect against this — only a try/except does |
|
||
| 39 | **Issue API scope: inspectors see assigned OR reported issues** | `GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status` all enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role. Pre-phase18 rows with `reported_by = NULL` surface only via `assigned_to`. |
|
||
| 40 | **`_issue_payload()` must return `photo_path` and `result_photos`** | iPad `pullAssignedIssues` stores these in `photoServerPaths` for display via `AsyncImage`; omitting them means server-created issues show no photos |
|
||
| 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row; calling it mid-transaction prematurely commits dirty session state. Snapshot any label strings needed for the audit call before the main commit if they come from ORM objects that may expire. |
|
||
| 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; comparing to `None` does not generate a "has no rows" predicate. Use `~.any()` which emits a proper `NOT EXISTS` subquery. |
|
||
| 43 | **`issues.index()` outerjoin must precede all filters** | `outerjoin(Area, Issue.area_id == Area.id)` is unconditional at the top of the query. Both the customer-scope block and the `facility_filter` block reference `Area.facility_id`; without a prior join the `facility_filter` path generates a cartesian product for non-customer users. |
|
||
|
||
---
|
||
|
||
## 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 |