Files
LT_Janitorial_Quality_Control/CLAUDE.md
T
2026-05-25 14:20:35 -04:00

37 KiB
Raw Blame History

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, inspector contract scoping)


Table of Contents

  1. Project Overview
  2. Tech Stack
  3. Repository Layout
  4. Environment & Configuration
  5. Database Models
  6. Role & Permission Matrix
  7. Blueprint Prefixes & Route Inventory
  8. Utility Modules
  9. Mobile API (Phase 7 / Phase A / Phase B / Phase C)
  10. iPad Native App
  11. Notification System
  12. SLA Engine
  13. Audit Trail
  14. PDF Export
  15. Scheduled Reports
  16. Rate Limiting
  17. Alembic Migration Chain
  18. Frontend Conventions
  19. Infrastructure
  20. Known Constraints & Hard Rules
  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

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_namefull_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)
inspector_assignments: id, user_id, project_id, created_at
    UniqueConstraint(user_id, project_id, name='uq_inspector_project')
    ForeignKey user_id → users(id) ON DELETE CASCADE
    ForeignKey project_id → projects(id) ON DELETE CASCADE

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

@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 non-customers. get_inspector_scope(user) — returns list[int] facility IDs for inspectors (empty list = no assignments = no access), None for non-inspectors. Derived from InspectorAssignment rows → project → active facilities.

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

{
    '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 0100 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

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
                                → phase20_inspector_assignments   ← 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:

flask db upgrade              # add mobile_photo_paths column
sudo systemctl restart gunicorn

MySQL ENUM Change Protocol (3 steps — always follow)

-- 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

# 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

bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
timeout = 30

Application Logging

  • RotatingFileHandlerlogs/jqc.log (5 × 5 MB)
  • StreamHandler → stdout (journalctl)

Nginx

  • client_max_body_size 50M
  • Passes X-Forwarded-For
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
2429 (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
3338 (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.
57 Inspector contract scoping: get_inspector_scope() — strict, no fallback Inspectors with NO InspectorAssignment rows see nothing (empty list, not None). Returns None only for non-inspector roles. All routes and API endpoints that currently filter by inspector_id or assigned_to/reported_by must instead filter by the facility list returned by get_inspector_scope().
58 Inspector scope covers all data in contracted facilities, not just own work Facility list, inspection list, issue list — all scoped to contracted facilities. Dashboard personal stats (today's work, avg score, trend) additionally filter by inspector_id so the productivity view stays personal. Issues show ALL facility issues, not just assigned ones.
59 assign_inspector_contracts route replaces the entire assignment set on POST The form sends the full checked list; existing assignments not in the POST body are deleted, new ones are inserted. Callers must always POST the complete desired set, not a diff. The page includes Select All / Deselect All buttons (JS-only, no server round-trip) and a live "N assigned" badge that updates on each checkbox change.

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 13 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