156 KiB
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: July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact)
Table of Contents
- Project Overview
- Tech Stack
- Repository Layout
- Environment & Configuration
- Database Models
- Role & Permission Matrix
- Blueprint Prefixes & Route Inventory
- Utility Modules
- Mobile API (Phase 7 / Phase A–E)
- iPad Native App
- Notification System
- SLA Engine
- Audit Trail
- PDF Export
- Scheduled Reports
- Rate Limiting
- Alembic Migration Chain
- Frontend Conventions
- Infrastructure
- Known Constraints & Hard Rules
- Change Philosophy
- Object Storage Migration (R2)
- Photo Capture-Time / Geo Overlay
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/Excel scorecards, scheduled email digests, Issues Aging, SLA Compliance, Follow-up Closure Rate, and per-facility Customer PDF Summary
- Audit trail — immutable log of every create/update/delete action
- Support chat — Groq AI chatbot for customers with preset FAQ chips; escalation to admin via ticketing system; customers can view and reply to their own tickets; admins manage tickets at
/support/admin/tickets - 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 |
| 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 + Phase E)
│ │ ├── photos.py # /api/v1/photos/upload (Phase B)
│ │ ├── stats.py # /api/v1/stats/dashboard (Phase B stats)
│ │ ├── comments.py # /api/v1/issues/<id>/comments (Phase D)
│ │ ├── 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)
│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23)
│ │ └── ...
│ ├── routes/
│ │ ├── support.py # /support/* — AI chat, ticket submit/list/detail (Phase 23)
│ │ └── ...
│ ├── 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
│ │ ├── reports/
│ │ │ ├── _subnav.html # Shared sub-nav include for all report pages
│ │ │ ├── index.html # Overview & Trends (score trend, facility scores + per-Contract filter, charts)
│ │ │ ├── facility.html # Per-facility detail report
│ │ │ ├── scorecard.html # Per-facility scorecard (trend, area scores, SLA, open issues) + PDF Summary button
│ │ │ ├── inspector_performance.html # Inspector KPI table + drill-down chart
│ │ │ ├── issues_aging.html # Open issues grouped by age bucket (R1)
│ │ │ ├── sla_compliance.html # SLA compliance by severity and facility (R2)
│ │ │ └── followup_closure.html # Follow-up re-inspection closure rate (R3)
│ │ ├── scheduled_reports/
│ │ │ └── index.html # Includes _subnav.html for Reports sub-nav
│ │ └── support/
│ │ ├── chat.html # Customer AI chatbot + FAQ chips + submit-ticket modal
│ │ ├── my_tickets.html # Customer: list of own tickets
│ │ ├── my_ticket_detail.html # Customer: ticket detail + staff replies + follow-up form
│ │ ├── admin_tickets.html # Admin: paginated ticket list with status filter tabs
│ │ └── admin_ticket_detail.html # Admin: ticket detail + reply form + status controls
│ └── utils/
├── migrations/
│ └── versions/
│ └── phase36_scheduled_inspections.py ← HEAD
└── ...
Note: `app/routes/broadcast.py` + `app/models/broadcast.py` (admin broadcasts) and
`app/routes/devices.py` (admin device registry, reads `api_device_tokens`) are also
part of the tree — see §7. Device registration on the API side lives in
`app/api/auth.py` only (there is no `app/api/devices.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. |
GROQ_API_KEY |
Optional. When set, enables the AI chatbot at /support/chat. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
GROQ_MODEL |
Optional. Groq model ID. Defaults to llama-3.3-70b-versatile. |
ENROLLMENT_NOTIFY_EMAILS |
Optional. Comma-separated extra addresses alerted on a new enrollment, in addition to every active admin account. For people who should be told but hold no JQC login. |
ENROLLMENT_DIR |
Optional. Directory for enrollment-form JSON submissions. Defaults to <instance_path>/enrollments (git-ignored). Created at boot. |
PHOTO_STAMP_ENABLED |
Optional, default true. Burns a capture-time + geo overlay into photos uploaded via POST /api/v1/photos/upload. Set false to store raw uploads. |
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, auditor, external_inspector
external_inspector (Phase 49): An inspector employed by the customer or a third party rather than by us. It has exactly the same capabilities as inspector and is scoped the same way — through InspectorAssignment rows resolved by get_inspector_scope(), i.e. an admin grants it the customer's contracts on the existing Assign Contracts page (/auth/users/<id>/assign-contracts, now gated on user.is_inspector). Strict scoping applies unchanged: no assignments = sees nothing.
The two roles are distinguished by display only. User.INSPECTOR_ROLES = ('inspector', 'external_inspector') and the User.is_inspector property are the single definition — every capability/scoping check tests is_inspector, never role == 'inspector' (rule 87). User.is_external_inspector and User.role_label (backed by the ROLE_LABELS map) drive the "External" badges: users list, dashboard Inspector Activity, Inspector Performance report (HTML + the Excel export, where the name cell is suffixed (External) rather than gaining a column so the index-based cell styling stays correct), and every assignee dropdown ((External) suffix — issues create/update, issue-list quick-assign, inspection flag-issue).
Assignable (rule 80 set becomes director/inspector/external_inspector/auditor, plus project_manager on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has mobile-API access — external_inspector is in the _ALLOWED_ROLES of every app/api/* module and falls into the inspector branch of every scoping check there. It gets its own Notification Matrix column (external_inspector), whose defaults mirror the Inspector column (see §11).
auditor (Phase 40): A staff role with the same access as project_manager (it is included in @project_manager_required and everywhere project_manager is checked) plus full issue-management powers — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new @issue_manager_required decorator). Auditor does NOT get issue deletion (that stays admin/director via @supervisor_required), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are assignable as an issue/inspection assignee; admin was removed from the assignable set at the same time (assignee dropdowns are now director/inspector/auditor, plus project_manager on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor has mobile-API access — it is included in the _ALLOWED_ROLES set of every app/api/* module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM.
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),
public_token VARCHAR(48) unique ← Phase 34 (QR landing page)
areas: id, facility_id (FK), name, area_type,
public_token VARCHAR(48) unique ← Phase 39 (per-area QR landing page)
public_token (Phase 34): unguessable per-facility token encoded in the facility's QR code. The QR points at /f/<public_token> — a login-free occupant summary page. Facility.generate_public_token() / ensure_public_token() mint one on demand; new facilities get one at creation, existing rows were backfilled by phase34. Rotating the token (regenerating it) invalidates any printed QR — intentional, for when a code is compromised.
Area.public_token (Phase 39): the same pattern applied per area. The QR points at /f/area/<public_token> — a login-free occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with area_id set. Area.generate_public_token() / ensure_public_token() mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate quality only: rating, counts, trend, and recent inspections with date + quality label — never raw score percentages, checklist/template names, inspector names, per-item scores, or severity/SLA). Routing: /f/area/<token> and /f/<token> do not collide (tokens are single-segment; area is a literal first segment).
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
submit_latitude DECIMAL(10,7) nullable ← Phase 25
submit_longitude DECIMAL(10,7) nullable ← Phase 25
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
vendor_name VARCHAR(100) nullable, ← Phase 26
vendor_contact VARCHAR(200) nullable, ← Phase 26
vendor_notes TEXT nullable, ← Phase 26
handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
facility_handler_name VARCHAR(100) nullable, ← Phase 35
facility_handler_contact VARCHAR(200) nullable, ← Phase 35
facility_handler_notes TEXT nullable, ← Phase 35
internal_handler_name VARCHAR(100) nullable, ← Phase 41
internal_handler_contact VARCHAR(200) nullable ← Phase 42
Handler (handler_type, Phase 35) — who is doing the work:
| Value | Meaning | Detail fields | assigned_to role |
|---|---|---|---|
internal (default) |
Janitorial Staff (our crew) | internal_handler_name/internal_handler_contact (Phase 41/42, free text — the crew member's name + phone/email, optional) |
the handler |
facility |
The facility's own staff | facility_handler_name/contact/notes (free text) |
internal follow-up owner |
vendor |
External contractor | vendor_name/contact/notes (Phase 26) |
internal follow-up owner |
Display labels are perspective-neutral (they read the same for staff and customers) with a descriptor line under the selector and a tooltip on badges: internal → "Janitorial Staff" ("Our janitorial crew handles it."), facility → "Facility Staff" ("The facility's own on-site staff handle it."), vendor → "External Vendor" ("An outside contractor handles it."). Labels/descriptions live in Issue.HANDLER_LABELS / HANDLER_DESCRIPTIONS, the WTForms handler_type choices, and the HANDLER_DESC JS map in both issue templates — keep these in sync. Do not use viewer-relative words like "Our"/"Your" for the stored categories.
Free-text internal_handler_name (Phase 41) + internal_handler_contact (Phase 42, phone/email) capture the janitorial crew member's name and contact when handler_type == 'internal' — the actual person doing the work, who may not be a system User. They are distinct from assigned_to (the follow-up owner) and are revealed by the same "Handled By" selector JS as the facility/vendor blocks (#internal_handler_block). Displayed under a "Staff" row (name + contact) on the issue detail when set.
assigned_to (a JQC User) is always available: it is the handler for internal, and the internal follow-up owner (e.g. the inspector who verifies/updates) for facility/vendor. Settable in two places, both with a "Handled By" selector that reveals the janitorial/facility/vendor sub-fields via JS:
- Log New Issue form (
issues/form.html) — at creation, for non-customer staff. Customer-created issues stayinternal(the handler UI is hidden for them, same asassigned_to). - Update Issue panel on the issue detail page (
issues/view.html) — triage after creation.
Triage of handler_type + facility/vendor detail fields on the update panel is admin/director/project_manager only (same gate as vendor fields); on the create form it follows the form's own access (admin/director create for staff). assigned_to editing on update remains admin/director. Issue list is filterable by ?handler_type= and shows a Facility/Vendor badge. Issue.handler_label gives the display string. Not yet exposed in the mobile API.
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; also public QR "report a problem" (photos 2–5) |
"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
IssueComment
issue_comments: id, issue_id (FK), user_id (FK), body, created_at,
status_at_time, is_customer_visible (BOOLEAN, default False) ← Phase 22
is_customer_visible: Staff comments are hidden from customers by default (False). Staff can tick "Share with customer" at post time to set True. Customer-authored comments are always stored as True. Customers see only is_customer_visible=True comments; staff see all.
FacilityScoreAlert
facility_score_alerts: id, facility_id (FK→facilities CASCADE), sent_at DATETIME,
current_avg DECIMAL(5,2), prior_avg DECIMAL(5,2), delta DECIMAL(5,2)
INDEX ix_fsa_facility_sent (facility_id, sent_at)
Records each score-trend alert dispatched for a facility. send_score_alerts() queries this table to skip re-alerting a facility within the last 24 hours, preventing notification storms on persistent score drops.
SupportTicket / SupportTicketReply
support_tickets: id, customer_id (FK→users SET NULL), facility_id (FK→facilities SET NULL),
subject VARCHAR(200), body TEXT, status VARCHAR(20) DEFAULT 'open',
created_at DATETIME
status values: open / answered / closed
support_ticket_replies: id, ticket_id (FK→support_tickets CASCADE), user_id (FK→users SET NULL),
body TEXT, created_at DATETIME
SupportChatSession / SupportChatMessage (Phase 37)
support_chat_sessions: id, customer_id (FK→users CASCADE, indexed),
created_at, updated_at (indexed)
support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE, indexed),
role ('user'|'assistant'), content TEXT, created_at
Persists the customer AI support chat. chat_message() writes both the user turn and the assistant reply into the session (creating one lazily on the first message; session.updated_at bumped each turn). chat() reloads the customer's most recent session into the chat window for continuity (unless ?new=1). Read-only history views exist for the customer (/support/my-conversations) and staff (/support/admin/conversations). SupportChatSession.preview = first user message; .message_count for list views. See §18 "Support Chat".
SupportKnowledge (Phase 38)
support_knowledge: id, title VARCHAR(200), content TEXT, active BOOL,
sort_order INT, created_by (FK→users SET NULL), created_at, updated_at
Admin-curated knowledge entries that "train" the AI chatbot without code changes. _system_prompt_with_kb() in routes/support.py appends every active entry (ordered by sort_order, id) to the base _SYSTEM_PROMPT on each chat request, soft-capped at _KB_MAX_CHARS (6000). Managed by admin/director at /support/admin/knowledge (list/new/edit/delete). The base _SYSTEM_PROMPT is a comprehensive, customer-scoped description of the app; the KB is the incremental, non-dev-editable layer on top. The chatbot is Groq/Llama (GROQ_MODEL, default llama-3.3-70b-versatile) — not fine-tuned; all "knowledge" is prompt context.
Flow:
- Customer submits ticket via chat page modal → status
open→ admins notified (in-app + email) - Admin replies → status auto-advances to
answered→ customer notified (in-app + email, link to/support/my-tickets/<id>) - Customer adds follow-up → status reverts to
open→ admins notified again - Admin can manually set:
open/answered/closed - Closed tickets cannot receive new replies from customers
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,
ios_version, registered_at, last_seen_at ← ios_version + last_seen_at added phase32
UniqueConstraint(user_id, device_id)
api_device_tokens is the single source of truth for device tracking. It is upserted by POST /api/v1/devices/register (in app/api/auth.py) on every app foreground and read by the admin Devices page (/admin/devices). The earlier device_registrations table / DeviceRegistration model was removed — see §17 phase30–32.
Broadcast
broadcasts: id, title VARCHAR(255), body TEXT, target_roles (JSON list of role strings),
sent_by_id (FK→users SET NULL), sent_at DATETIME, recipient_count INT
Admin-authored broadcast messages. Sending a broadcast fans out one Notification row per targeted user; the iPad picks them up through its existing GET /api/v1/notifications?since=... poll — no dedicated broadcast API endpoint exists. recipient_count snapshots how many notifications were created. Managed at /admin/broadcast (see §7 broadcast blueprint).
ContractNotificationRecipient
contract_notification_recipients:
id, project_id (FK→projects CASCADE, indexed),
user_id (FK→users CASCADE, nullable, indexed), -- staff-user recipient
email VARCHAR(200) nullable, -- external email recipient
event_types TEXT (JSON list of event_type keys), created_at
Per-contract additional notification recipients. Each row is ONE extra recipient attached to a Contract who is notified — for the event_types they subscribe to — whenever those events fire within that contract's facilities, in addition to the global NotificationMatrix routing. Exactly one of user_id / email is set (enforced in the route, not the DB):
user_idset → existing staff user → in-app notification + emailemailset → free-form external address → email only
event_types is a JSON list of MATRIX_EVENTS keys. A recipient fires only when the event is in its list. Managed admin-only on the Contract detail page (/projects/<id>) via add_notify_recipient / remove_notify_recipient. Dispatch is resolved centrally in notify_by_matrix() — see §11.
ScheduledInspection
scheduled_inspections:
id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE),
inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'),
next_due_date DATE, active BOOL, notes TEXT, created_by, created_at,
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL,
weekdays VARCHAR(20) nullable, -- Phase 43: CSV weekday ints, Mon=0, e.g. '0,2,4'
month_mode VARCHAR(20) nullable, -- Phase 43: 'day_of_month' | 'nth_weekday'
day_of_month SMALLINT nullable, -- Phase 43
nth_week SMALLINT nullable, -- Phase 43: 1–5, or -1 = last
nth_weekday SMALLINT nullable, -- Phase 43: 0–6, Mon=0
acknowledged_at DATETIME nullable -- Phase 47: inspector confirmed receipt
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36
Recurrence detail (Phase 43). frequency says how often; these columns say which day:
| frequency | columns used | example |
|---|---|---|
once / daily |
none (all NULL) | — |
weekly |
weekdays |
'0,2,4' → Mon/Wed/Fri |
monthly + month_mode='day_of_month' |
day_of_month |
the 15th (clamped to the month's last day) |
monthly + month_mode='nth_weekday' |
nth_week, nth_weekday |
2nd Tuesday (nth_week=-1 → last) |
All are nullable and legacy phase36 rows keep NULLs, falling back to _add_interval()'s "+7 days" / "same day next month" — no schedule changes cadence on deploy. _apply_recurrence() (in the blueprint) clears the columns that don't apply to the chosen frequency, so a weekly→monthly switch can't leave stale weekdays behind.
ScheduledInspection.next_occurrence_after(d)— first occurrence strictly afterd, honouring the rule. Used byfulfill(); a Mon/Wed/Fri schedule rolls Mon→Wed→Fri→Mon, so one row yields three inspections a week.align_due_date(d)— snaps the manager's picked start date forward to the first matching day (pick a Tuesday for Mon/Wed/Fri → get that Wednesday). Applied on both create and edit.recurrence_label— display string ("Weekly · Mon, Wed, Fri"), shown on the schedule list and the dashboard panel in place of the barefrequency_label.weekday_list/set_weekdays()— parse/format the CSV column.ScheduledInspectionForm(obj=sched)copies the raw CSV into the multi-select, so the edit route re-assignsform.weekdays.data = sched.weekday_liston GET.- A requested 5th weekday that doesn't exist in a month falls back to the 4th;
day_of_month=31clamps to Feb 28/29. Every month yields a valid date.
Duplicate-Start guard. start() returns the existing in_progress inspection for the schedule instead of creating a second one, and both the schedule list and the dashboard panel show Continue (via _open_inspection_ids()) rather than Start while one is underway.
A plan, not an inspection. Names a facility + template + assigned inspector + next_due_date. Lifecycle:
- The assigned inspector (or a manager) clicks Start →
scheduled_inspections.startcreates a normalin_progressInspection withscheduled_inspection_idset, then redirects to the execute flow. - On completion (execute route, status →
completed),ScheduledInspection.fulfill()runs in the same atomic commit:once→active=False; recurring →next_due_daterolls forward past today via_add_interval()and the three*_notifiedflags reset. - End date (phase44).
end_dateis the manager's boundary; NULL = forever, and it is forced NULL foronce. Inclusive — an occurrence landing exactly on it still runs. Two enforcement points, both needed:fulfill()deactivates when the rolled-forwardnext_due_datepasses the boundary (the schedule that ends by being completed), andrun_reminders()callsexpire_if_past_end_date()on every active schedule before doing any reminder work (the schedule that reaches its boundary without ever being done — otherwise it re-alerts as overdue forever).next_due_dateis left unclamped on expiry so the row shows which occurrence it stopped before. - Form validation.
ScheduledInspectionForm.validate()rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone:align_due_date()can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so_reject_if_past_end_date()re-checks after_apply_recurrence()in both create and edit. Edit rolls back first —schedis persistent and already mutated at that point. - Three status states in the list: Active, Ended (
is_expired— ran its course), Inactive (a manager switched it off). - Assignment notification (immediate): on create, the assigned inspector gets an in-app + email "assigned to you" notification; on edit, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via
_notify_assignee()in the blueprint usingevent_type=EVENT_SCHEDULED_INSPECTION. - Receipt acknowledgement (phase47).
acknowledged_atrecords when the assigned inspector confirms they received the request — a way for the manager to see the assignment was seen, distinct from starting it.POST /<id>/acknowledgeis assignee-only (like Start; a manager cannot confirm on someone's behalf) and idempotent; on first confirm the schedule's creator is notified via_notify_creator_acknowledged()(event_type=EVENT_SCHEDULED_INSPECTION, skipped when creator is inactive or is the inspector). The stamp-log-notify body is factored into_do_acknowledge(sched, actor_username), shared by the POST route and the email-token route. Once per assignment, not per occurrence: it is NOT reset when a recurring schedule rolls forward (fulfill()leaves it), but the edit route resets it to NULL when the inspector changes so a new assignee must re-confirm. Surfaced with a Confirmed/Awaiting badge + a "Confirm receipt" button (assignee only, active schedules) on both the scheduled-inspections list and the dashboard panel.ScheduledInspection.is_acknowledgedis the boolean helper. Exposed read-only in the API payload (acknowledged_at) — confirming stays on the web per rule 77; an iPad confirm action would be a follow-up.- Confirm from the email (phase47). The assignment email (and the advance/due reminder emails while still unconfirmed) carry a green "Confirm receipt" button beside "View Details". It links to
GET /scheduled-inspections/confirm/<token>— a login-free landing (no@login_required, same pattern as thepublicblueprint) authorised by anitsdangerous.URLSafeTimedSerializertoken (saltscheduled-inspection-ack, 30-day max age, signed withSECRET_KEY— no DB column). The token binds{sid, iid}so a schedule reassigned to another inspector invalidates the previous assignee's emailed link (the route checkssched.inspector_id == token iid). The route renders the standalonescheduled_inspections/confirm_result.htmlwith astatusof confirmed / already / reassigned / inactive / expired / invalid / missing; the acknowledgement is idempotent so a re-click or email-client prefetch is harmless. The email button is built by_confirm_action(sched)(returns None once acknowledged), threaded intonotify(..., extra_action={'label','url'})— an email-only second button;extra_action.urlis absolute and is NOT prefixed withbase_url. In-app notifications are unchanged.
- Confirm from the email (phase47). The assignment email (and the advance/due reminder emails while still unconfirmed) carry a green "Confirm receipt" button beside "View Details". It links to
- List tabs (phase47). The scheduled-inspections list (
index()) has two tabs via?tab=pending|completed(defaultpending): Pending = active schedules (active == True, ordered by next due date), Completed = closed schedules (active == False, ordered bylast_completed_atdesc — fulfilled one-times, ended recurring, and manually-deactivated rows). Exhaustive, non-overlapping partition; the in-row Status badge (Active / Ended / Inactive) disambiguates the closed bucket, and a "last completed" date is shown when set.pending_count/completed_countdrive the tab pill badges. - Reminders are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the
*_notifiedflags. Usesnotify()withevent_type=EVENT_SCHEDULED_INSPECTION. - Dashboard shows an upcoming (next 7 days) / overdue panel for non-customers (inspectors see only their own).
- Instructions (July 2026).
ScheduledInspectionForm.notesis labelled "Instructions" andscheduled_inspections/form.htmlexplains that the text reaches the inspector. The field name,ScheduledInspection.notes, thescheduled_inspections.notescolumn and the API keynotesare all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places:inspections/execute.htmlrenders an indigo panel between the header and the form grid, guarded oninspection.scheduled_inspection and .notes(NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form. - "Scheduled" badge: an inspection started from a schedule carries
scheduled_inspection_id.Inspection.scheduled_inspection(relationship, foreign_keys on that column) resolves the source schedule (None if ad-hoc or the schedule was later deleted). The inspection detail view header shows abi-calendar-check"Scheduled · " badge, and the inspection list shows a compact "Scheduled" pill next to the template name — both gated onscheduled_inspection_idbeing set.
Management (/scheduled-inspections/new|edit|delete) is @project_manager_required; Start is the assigned inspector ONLY (sched.inspector_id == current_user.id) — managers do NOT get a Start button and GET /<id>/start 403s for anyone who isn't the assignee (the inspection is theirs to do; a manager who must run it assigns it to themselves). The Start button is hidden for non-assignees on both the scheduled-inspections list and the dashboard panel. Inspectors' list/dashboard views are scoped to their own inspector_id.
6. Role & Permission Matrix
auditor reads as a project_manager column below, with these overrides: Issues (quick-assign) ✅, Issue verification ✅, and it appears in the Issues (create/assign) and Issue verification rows as ✅. It never gains issue delete or any admin/director-only row PM lacks. See the auditor note in §5.
| Area | admin | director | project_manager | inspector | customer |
|---|---|---|---|---|---|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
| Users | ✅ | ✅ | ❌ | ❌ | ❌ |
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
| Facilities | ✅ | ✅ | ✅ | read | scoped |
| Facility QR (view/print) | ✅ | ✅ | ✅ | ✅ | scoped |
| Facility QR (regenerate) | ✅ | ✅ | ❌ | ❌ | scoped |
| Contracts | ✅ | ✅ | ✅ | read | scoped |
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
| Inspection follow-up (request) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities |
| Inspection follow-up (clear) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own |
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only |
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ |
| Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only |
| 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', 'auditor')
@issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete)
@customer_required # role == 'customer' only
# Not a decorator, but the same idea for the two inspector roles:
# user.is_inspector → role in ('inspector', 'external_inspector')
# Never write `role == 'inspector'` for a capability or scoping check.
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 + QR code: /<id>/qr printable page, /<id>/qr.png image, POST /<id>/qr/regenerate (invalidates old printed code), /qr/print-all[?contract_id=] bulk sheet. Per-area QR (Phase 39): /areas/<id>/qr, /areas/<id>/qr.png, POST /areas/<id>/qr/regenerate — mirror the facility QR routes; scope enforced by _area_for_qr_or_403() via the area's parent facility. Customers may use all QR actions (including regenerate) for their own assigned facilities; inspectors/PM/admin/director for any. Scope enforced by _facility_for_qr_or_403() (customers) / get_customer_scope (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). QR print/export page: GET /qr/print-all is a selectable sheet with filters ?contract_id= / ?facility_id= / ?include_areas=1 (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a <label> wrapping a checkbox; Print Selected (JS toggles body.print-selected-only so @media print hides unticked cards) and Export Selected to PDF (POST /qr/export-pdf, repeated facility_ids/area_ids, scope re-checked per id via the _*_for_qr_or_403() helpers, streams generate_qr_codes_pdf() output; logs ACTION_EXPORT). Inspectors 403. QR PNG bytes for the PDF come from _qr_png_bytes(url). |
public |
/f |
No login. GET /<token> occupant facility summary + POST /<token>/report occupant issue report; GET /area/<token> per-area summary + POST /area/<token>/report (Phase 39, files with area_id set). Report form accepts up to 5 photos (_save_report_photos() → photo_path + mobile_photo_paths). All report POSTs rate-limited 5/hour, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by public_token or 404. |
projects |
/projects |
CRUD + customer assignment management + notification-recipient add/remove (/<id>/notify-recipients/add, /notify-recipients/<rid>/remove — admin only) |
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. verify / bulk-verify / verification-queue are @issue_manager_required (admin/director/auditor); delete stays @supervisor_required (admin/director). |
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/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
scheduled_reports |
/scheduled-reports |
CRUD + manual trigger (accessible via Reports sub-nav) |
scheduled_inspections |
/scheduled-inspections |
list (?tab=pending|completed — Phase 47), new/edit/delete (PM+), GET /<id>/start (assigned inspector only → creates linked inspection; 403 for non-assignees incl. managers), POST /<id>/acknowledge (assigned inspector only → confirms receipt, sets acknowledged_at, notifies creator; idempotent — Phase 47), GET /confirm/<token> (login-free one-click email confirm; signed itsdangerous token binding schedule+inspector — Phase 47), POST /run (cron reminders, token=DIGEST_SECRET) |
support |
/support |
GET /chat (loads latest saved session; ?new=1 to start fresh), POST /chat/message (AJAX→Groq; persists user+assistant turns, returns session_id), GET /my-conversations, GET /my-conversations/<id> (customer chat history), GET /admin/conversations, GET /admin/conversations/<id> (staff, read-only), GET /admin/knowledge + /new, /<id>/edit, /<id>/delete (admin/director — chatbot knowledge base), POST /tickets, GET /my-tickets, GET/POST /my-tickets/<id>, GET /admin/tickets, GET/POST /admin/tickets/<id> |
enrollment |
/enrollment |
Self-contained onboarding intake — see §24. GET/POST / (login-free public form), GET /admin (admin inbox), GET/POST /admin/<id> (detail + office-use fields), GET /admin/<id>.json, GET /admin/export.csv. Lives in app/enrollment/ with its own templates; touches no DB table. |
broadcast |
/admin/broadcast |
GET / (compose + history), POST /send (admin-only; fans out one Notification per targeted user) |
devices |
/admin/devices |
GET / (device list from api_device_tokens), POST /notify (admin-only) |
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 |
api_stats |
/api/v1 |
GET /stats/dashboard — inspector-scoped KPIs with severity breakdown (Phase B) |
api_comments |
/api/v1 |
GET /issues/<id>/comments, POST /issues/<id>/comments (Phase D) |
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.
mail_utils.py
branded_sender(base_url=None) — returns a Flask-Mail (display_name, address) sender tuple whose identity tracks the current host, used by the customer invite email. Two things vary independently:
- Display name (always applied, zero DNS): per-domain brand from
BRAND_NAMES(fallbackDEFAULT_BRAND_NAME), e.g."Gov Services QC". - From address (gated): the authenticated local part (
jqc.noreply) with the host's registrable domain — but only for the authenticated sender's own domain or a domain listed inSENDER_AUTHORIZED_DOMAINS. Every other domain keeps the authenticatedMAIL_DEFAULT_SENDERaddress so it still passes SPF/DMARC and delivers.
So with no DNS work an invite from jqc.govservicesinc.com sends From: "Gov Services QC" <jqc.noreply@ltservicesinc.com> (branded name, deliverable address). After that domain's SPF include: + DKIM are live, add it to SENDER_AUTHORIZED_DOMAINS and it upgrades to <jqc.noreply@govservicesinc.com> — no code change. Falls back to the bare authenticated sender string for unparseable hosts (localhost, empty). Edit BRAND_NAMES / SENDER_AUTHORIZED_DOMAINS as brands and DNS come online. See rule 64.
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.
Public functions:
generate_inspection_pdf(inspection, form_fields, form_data, issues, static_folder)— per-inspection PDFgenerate_issues_list_pdf(issues, filter_summary)— landscape issues list PDF (from issues list export)generate_inspections_list_pdf(inspections, filter_summary)— landscape inspections list PDFgenerate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)— customer-facing one-page facility summary PDF (Phase R4)generate_qr_codes_pdf(items, filter_summary='')— grid of selected facility/area QR codes.items= list of{title, subtitle, caption, png(bytes)}; 3-per-row portrait sheet. BacksPOST /facilities/qr/export-pdf.
_build_styles() registered style names: ReportTitle, ReportSub, SectionHead, FieldLabel, FieldValue, MetaLabel, MetaValue, IssueDesc, FooterStyle, SummaryTitle, ReportSubtitle, Meta, ScoreValue, ScoreLabel, SectionHeader, TableHeader, TableCell
The last eight styles (SummaryTitle through TableCell) were added for the facility summary PDF and are available for any future customer-facing PDF functions.
9. Mobile API (Phase 7 / Phase A–E)
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
POST /api/v1/auth/login→ access token (60 min JWT) + refresh token (30 day opaque hex)- Bearer token on every request
POST /api/v1/auth/refresh→ token rotation (old revoked, new issued)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, stamped, captured_at, capture_source. Optional form fields captured_at (ISO-8601), latitude, longitude drive the burned-in timestamp/geo overlay — see §23. |
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. |
Phase B (Stats) Endpoint
| Endpoint | Auth | Description |
|---|---|---|
GET /api/v1/stats/dashboard |
jwt_required | Inspector-scoped KPIs: today_inspections, completed_today, open_issues, avg_score_30d, pending_followups, sla_breached, sla_at_risk, severity_breakdown (dict: critical/high/medium/low). Inspectors scoped to contracted facilities. Admins/directors/PMs get org-wide numbers. Customers get 403. |
Phase D (Comments) Endpoints
| Endpoint | Auth | Description |
|---|---|---|
GET /api/v1/issues/<id>/comments |
jwt_required | All comments oldest-first. Returns: id, issue_id, author_name, author_role, status_at_time, body, created_at. Inspectors limited to contracted facilities. |
POST /api/v1/issues/<id>/comments |
jwt_required | Add a comment. Body: { "body": "..." }. Fires notify_by_matrix('issue_comment'). Calls log_action() after commit. |
Phase E Additions to Existing Endpoints
_issue_payload() in issues.py now returns area_name and assigned_to_name (both nullable). These populate LocalIssue.areaNameCache and LocalIssue.assignedToName on the iPad after every pullAssignedIssues(). refreshStatusFromServer() also refreshes them on demand.
stats.py now returns severity_breakdown dict alongside the existing KPIs. Derived from the already-loaded open_issues_all list — zero extra DB queries.
Scheduled Inspections + Issue Handler Endpoints (July 2026)
| Endpoint | Auth | Description |
|---|---|---|
GET /api/v1/scheduled-inspections |
jwt_required | Active scheduled/recurring assignments (app/api/scheduled.py, new blueprint). Inspector: only rows where inspector_id == self. admin/director/PM: all active. Sorted by next_due_date. Returns per row: id, facility_id, facility_name, template_id, template_name, inspector_id, frequency, frequency_label, next_due_date (ISO date), is_overdue, notes, plus total/limit/offset. Powers the iPad "Scheduled" section on Dashboard + My Inspections. Read-only as a collection — schedules are created/edited on the web only — but the iPad does fulfil them by submitting an inspection with scheduled_inspection_id (see below). |
PATCH /api/v1/issues/<id>/handler |
jwt_required | Set "Handled By" from the iPad (update_issue_handler). Body: { "handler_type": "internal"|"facility"|"vendor", ...optional detail keys }. Detail keys (facility_handler_name/contact/notes, vendor_name/contact/notes) are updated only when present; empty string clears a field. log_action() after commit. |
Handler permission divergence — deliberate (see rule 78). The web issue form limits handler edits to admin/director/PM. This API endpoint additionally allows the assigned inspector, scoped by get_inspector_scope() (403 if the issue's facility isn't in their contracted set). The iPad is a field tool; inspectors set the handler from Issue Detail. Do not "align" the API back to the web restriction without explicit direction.
Schedule fulfilment from the iPad (July 2026 fix). POST /api/v1/inspections and PATCH /api/v1/inspections/<id> both accept scheduled_inspection_id, and both call _fulfill_schedule() in the same atomic commit when the inspection reaches completed — mirroring the web execute route. Previously the iPad's Start passed only facility + template, so the inspection landed with scheduled_inspection_id = NULL: the schedule was never fulfilled (banner stayed on every dashboard, iPad "Scheduled" section never cleared) and the web inspection list showed no "Scheduled" badge. All three symptoms had this single cause.
- PATCH fulfils only on the
draft → completedtransition, so re-PATCHing a completed inspection can't roll a recurring schedule forward twice. POST is guarded by the existingmobile_local_ididempotency check (a duplicate returns early, before fulfilment). _resolve_schedule()is deliberately NON-BLOCKING — see rule 83._inspection_payload()returnsscheduled_inspection_id.- No SyncManager change was needed:
pullScheduledInspections()already runs afterprocessInspectionQueue()in the sametriggerSync()pass and deletes rows the server no longer returns, so the iPad banner clears on the same sync that submits the inspection.
The new scheduled blueprint is registered in app/api/__init__.py and CSRF-exempted in app/__init__.py (csrf.exempt(_api_scheduled_bp) — parent-exempt does not cascade to child blueprints, per the CSRF pattern above).
No migration was needed for either feature: the scheduled_inspections table (phase36) and the issue handler columns (phase35) already existed; both additions are pure serialization + one new route.
{
'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)
'result_photos', # resolution photos added via web form (list)
# Phase A additions:
'result_notes', # resolution notes entered by web staff
'verified_at', # ISO 8601 datetime when fix was verified (nullable)
'verification_note', # note from the verifier (nullable)
'reported_by_name', # display_name of User who filed the issue (nullable)
# Phase E additions:
'area_name', # name of the Area the issue was flagged in (nullable)
'assigned_to_name', # display_name of currently assigned User (nullable)
# Handler ("Handled By", July 2026) additions:
'handler_type', # 'internal' | 'facility' | 'vendor' (defaults 'internal')
'handler_label', # human-readable label (Issue.handler_label property)
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable
'vendor_name', 'vendor_contact', 'vendor_notes', # nullable
'internal_handler_name', 'internal_handler_contact', # Phase 41/42 — janitorial staff name + contact (nullable)
}
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 /issuesreturns issues whereassigned_to == current_user.idORreported_by == current_user.id. - Admin / Director / Project Manager:
GET /issuesreturns all non-resolved issues (default) or filtered by?status=. GET /issues/<id>andPATCH /issues/<id>/statusandPATCH /issues/<id>/photosall 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) orjqc1.ltservicesinc.com(secondary) — server is user-selectable at login and in Settings. - Server selection is persisted to
UserDefaultsviaServerConfig. 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_pathson the server — never throughresult_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'
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36
EVENT_FOLLOWUP_REQUESTED = 'followup_requested' ← Phase 46
External Inspector column (Phase 49)
MATRIX_ROLES gains ('external_inspector', 'External Inspector'), and notify_by_matrix()'s role_to_db map routes it to the external_inspector DB role. MATRIX_DEFAULTS mirrors the Inspector column for every event (a comprehension, not 14 more literals) so a future event added for inspector automatically gets a matching external default. The inspection_completed scoping below applies to both inspector columns — without that, enabling the External column would notify every third-party inspector on every submission.
Inspector role scoping for inspection_completed
notify_by_matrix() special-cases the inspector role for the inspection_completed event: instead of notifying every active inspector, it notifies only the inspection's own inspector (Inspection.inspector_id, resolved from the passed inspection_id). So enabling the "Inspector" column for "Inspection completed" in the matrix alerts just the inspector who submitted that inspection — not the whole inspector pool. All three dispatch sites (web routes/inspections.py, both mobile-API api/inspections.py) pass inspection_id, so the scoping applies uniformly; if inspection_id is ever omitted for this event, the inspector role notifies no one (fail-closed). Other roles/events are unaffected.
Customer-requested follow-up (Phase 46)
inspections.flag_followup is no longer @supervisor_required. It gates in the body instead: admin/director as before, plus customers for their own facilities — a client unhappy with a result asks for a re-inspection directly instead of going through support. Inspector / PM / auditor stay refused (403).
Customers can only request. clear_followup remains admin/director, reinspect() still refuses customers, and the "Start Re-inspection" button inside the follow-up alert is hidden from them (it 403'd on click before). Three extra customer-only guards in the route:
- facility must be in
get_customer_scope()— else 403 (a crafted POST must not reach another client's inspection); - inspection must be
completed— nothing to follow up on otherwise; - if
follow_up_requiredis already set the request is a no-op, so a repeat submission can't overwrite the pending note/attribution.
Attribution (follow_up_requested_by / follow_up_requested_at, phase46) records who asked and when; clear_followup nulls both. inspections/view.html renders a "Requested by customer" / "Requested by staff" badge from inspection.follow_up_requester.role, so staff can see at a glance that a client is waiting.
Dispatch goes through notify_by_matrix(EVENT_FOLLOWUP_REQUESTED, ...) — the new followup_requested matrix event (admin/director/PM on by default). The inspection's own inspector is notified directly by the route and passed in exclude_user_ids so they aren't double-notified; the requester is excluded too. Routing via the matrix (rather than hardcoding managers) is what makes per-contract recipients fire — rule 73. Without it a customer request would reach only the inspector and nobody would own scheduling the re-inspection.
Per-Contract Additional Recipients (Phase 33)
notify_by_matrix() is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls _notify_contract_recipients(), which:
- Resolves the owning contract via
_resolve_project_id(facility_id, issue_id, inspection_id)— triesfacility_id, then the issue's facility (orissue.area.facility_id), then the inspection's facility. - Loads
ContractNotificationRecipientrows for that project and notifies each one whoseevent_typescontains the firing event. - Deduplicates against users already notified this dispatch (shared
notifiedset) and emails already sent (sharedsent_emailsset), so a user who is both a matrix role AND a contract recipient gets exactly one notification.
Contract recipients fire regardless of matrix role toggles — they are additive, not gated by the matrix. Staff-user recipients use respect_preferences=False (contract config is authoritative, mirroring matrix broadcasts). Commit is the caller's responsibility, same as the rest of notify_by_matrix().
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 * * * |
POST /notifications/check-score-trends |
Facility score drop alerts (Phase 27) | 0 8 * * * |
POST /scheduled-inspections/run |
Scheduled inspection reminders — advance/due to inspector, overdue to admin/director (Phase 36) | */30 * * * * |
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
→ phase21_template_active
→ phase21_performance_indexes
→ phase22_comment_visibility
→ phase23_support_tickets
→ phase24_notify_defaults
→ phase25_inspection_gps
→ phase26_issue_vendor
→ phase27_score_alerts
→ phase28_fix_inspection_notify
→ phase29_broadcasts
→ phase30_device_registry
→ phase31_device_registry
→ phase32_device_token_columns
→ phase33_contract_recipients
→ phase34_facility_qr
→ phase35_issue_handler
→ phase36_scheduled_insp
→ phase37_support_chat
→ phase38_support_knowledge
→ phase39_area_public_token
→ phase40_auditor_role
→ phase41_internal_handler
→ phase42_internal_contact
→ phase43_sched_recurrence
→ phase44_sched_end_date
→ phase45_sched_parent_insp
→ phase46_followup_req_by
→ phase47_sched_acknowledged
→ phase48_user_ui_theme
→ phase49_external_inspector ← HEAD
#### phase49 — External Inspector role
Revision id `phase49_external_inspector` (file `phase49_external_inspector_role.py`, down_revision `phase48_user_ui_theme` — note phase48, the design A/B test, is the real head, NOT phase47). Adds `external_inspector` to the `users.role` ENUM. **Pure ENUM expansion** (adds a value, migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `external_inspector` rows to `inspector` first, which preserves their `InspectorAssignment` scoping exactly.
**No matrix rows are seeded.** `MATRIX_DEFAULTS` mirrors every `('<event>', 'inspector')` default into `('<event>', 'external_inspector')` at import time, and `is_enabled()` falls back to that default when a row is absent — so an un-seeded install behaves identically to the Inspector column until an admin saves the matrix page.
**Deploy order:**
```bash
flask db upgrade # expands users.role ENUM with 'external_inspector'
sudo systemctl restart gunicorn
#### phase47 — scheduled inspection receipt acknowledgement
Revision id `phase47_sched_acknowledged` (file `phase47_sched_acknowledged.py`, down_revision `phase46_followup_req_by`). Adds `scheduled_inspections.acknowledged_at DATETIME NULL` — when the assigned inspector confirms they received the scheduled request. Backs the **receipt acknowledgement** feature — see §5 `ScheduledInspection` "Receipt acknowledgement". **No backfill**: legacy rows keep NULL and render as "Awaiting" confirmation, the correct initial state. `INFORMATION_SCHEMA` column check — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
phase46 — follow-up request attribution
Revision id phase46_followup_req_by (file phase46_followup_requested_by.py, down_revision phase45_sched_parent_insp). Adds inspections.follow_up_requested_by INT NULL (FK → users.id ON DELETE SET NULL) and follow_up_requested_at DATETIME NULL. Backs customer-requested follow-ups — see §11 "Customer-requested follow-up". No backfill: legacy rows keep NULL and render as an unattributed follow-up exactly as before.
Breaking detail: this is the second FK from inspections to users, which made User.inspections ambiguous at mapper-configure time (AmbiguousForeignKeysError on the first ORM use, not at import). User.inspections now declares foreign_keys='Inspection.inspector_id' — it means "inspections I performed". Any future FK from inspections to users needs the same treatment.
INFORMATION_SCHEMA column + constraint checks — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase44 — scheduled inspection end date
Revision id phase44_sched_end_date (file phase44_scheduled_end_date.py, down_revision phase43_sched_recurrence). Adds scheduled_inspections.end_date DATE NULL — the last date a recurring schedule may produce an occurrence. No backfill: NULL means "repeat indefinitely", which is exactly what every existing row does today, so nothing changes cadence on deploy. Splits the two meanings next_due_date was carrying (see §5 ScheduledInspection and rule 85). INFORMATION_SCHEMA column-existence check — safe to re-run.
phase43_sched_recurrence
Revision id phase43_sched_recurrence. Adds the five nullable recurrence-detail columns to scheduled_inspections (weekdays, month_mode, day_of_month, nth_week, nth_weekday) so weekly schedules can name their weekdays and monthly schedules can use either a day-of-month or an nth-weekday rule — see §5 ScheduledInspection. No backfill: existing rows keep NULLs and retain their current cadence. month_mode is VARCHAR, not ENUM, so a future recurrence style needs no 3-step ENUM migration (rule 3). INFORMATION_SCHEMA column-existence checks — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase21_performance_indexes
Adds four composite indexes covering the highest-traffic multi-column query patterns: (facility_id, inspection_date) and (inspector_id, inspection_date) and (status, inspection_date) on inspections; (facility_id, status) on issues. All single-column indexes already exist from phase12. Uses INFORMATION_SCHEMA.STATISTICS existence check — safe to re-run.
Deploy order for phase21:
flask db upgrade
sudo systemctl restart gunicorn
phase21_template_active
Adds active boolean column to inspection_templates so templates can be deactivated without deletion. Inactive templates are hidden from the inspection-start form but remain accessible in the template management UI. Uses INFORMATION_SCHEMA column existence check — safe to re-run.
phase23_support_tickets
Creates support_tickets and support_ticket_replies tables. Uses table existence check — safe to re-run.
Deploy order:
flask db upgrade
pip install groq # if not already installed
# Set GROQ_API_KEY in environment / systemd unit
sudo systemctl restart gunicorn
phase24_notify_defaults
Data-only migration. Sets enabled=True for ('issue_created', 'admin') and ('issue_created', 'director') rows in notification_matrix if they already exist. Rows that do not yet exist are seeded at runtime by MATRIX_DEFAULTS. No schema change — no existence check needed, UPDATE on missing rows is a no-op.
phase25_inspection_gps
Adds submit_latitude DECIMAL(10,7) NULL and submit_longitude DECIMAL(10,7) NULL to inspections. Populated at submit time — by browser Geolocation API (web) or CoreLocation (iPad). Null for all existing rows. Uses INFORMATION_SCHEMA column existence check — safe to re-run.
inspections/view.html shows a Google Maps embed (admin/director only) when both columns are non-null.
iPad behaviour: InspectionLocationManager begins acquiring a fix when the submit confirm dialog appears. GPS is captured into LocalInspection.submitLatitude/submitLongitude and sent in the POST /api/v1/inspections body. The PATCH endpoint does not accept GPS — creation-time capture only.
phase26_issue_vendor
Adds three nullable columns to issues:
| Column | Type | Purpose |
|---|---|---|
vendor_name |
VARCHAR(100) |
External contractor or vendor name |
vendor_contact |
VARCHAR(200) |
Phone or email for the vendor |
vendor_notes |
TEXT |
Notes about what the vendor is handling |
Displayed in issues/view.html and editable via IssueForm (form.html). Staff-only — not exposed in mobile API. Uses INFORMATION_SCHEMA existence check — safe to re-run.
phase27_score_alerts
Creates facility_score_alerts table. Used by send_score_alerts() in sla.py for 24-hour deduplication of score-drop notifications. Uses table existence check — safe to re-run.
phase28_fix_inspection_notify
Data-only migration. Resets the notification_matrix rows for ('inspection_completed', 'director'), ('inspection_completed', 'admin'), and ('inspection_completed', 'customer') to enabled=True, matching MATRIX_DEFAULTS. These had been inadvertently disabled (likely via an accidental checkbox save on the matrix page), suppressing inspection-completed notifications from both web and mobile API. No schema change.
phase29_broadcasts
Creates the broadcasts table backing the admin broadcast feature (see §5 Broadcast and §7 broadcast blueprint). Uses table existence check — safe to re-run.
phase30–32_device_registry (consolidation — read as a unit)
These three migrations are the history of a false start in device tracking. Net effect after all three: the app tracks devices exclusively in api_device_tokens (DeviceToken model); the short-lived device_registrations table and its DeviceRegistration model no longer exist.
- phase30_device_registry — created a separate
device_registrationstable (the abandoned approach). - phase31_device_registry — drops
device_registrations(DROP TABLE IF EXISTS). - phase32_device_token_columns — the live path. Adds
ios_version VARCHAR(20)andlast_seen_at DATETIMEtoapi_device_tokens(phase31's ALTERs were recorded-but-never-executed, so phase32 re-applies them viaINFORMATION_SCHEMAexistence checks) and drops the orphaneddevice_registrationstable if still present. Safe to re-run.
The dead DeviceRegistration model, app/api/devices.py endpoint, and api_devices blueprint were removed (July 2026). They defined a second POST /api/v1/devices/register that was shadowed at routing time by the api_auth copy and would have crashed anyway (it queried the dropped device_registrations table). Device registration now has a single implementation: register_device() in app/api/auth.py, writing to api_device_tokens. Do not reintroduce a competing device model or a duplicate register route.
phase33_contract_recipients
Creates the contract_notification_recipients table backing per-contract additional notification recipients (see §5 ContractNotificationRecipient and §11). Uses table existence check — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase34_facility_qr
Revision id phase34_facility_qr (file phase34_facility_public_token.py). Adds facilities.public_token VARCHAR(48) (unguessable, unique) and backfills a token for every existing facility in the migration body, then creates the uq_facility_public_token unique index. Backs the public QR landing pages (see §5 Facility.public_token and the Public Facility QR section in §7). Uses INFORMATION_SCHEMA checks — safe to re-run.
Deploy order:
pip install qrcode # new dependency (Pillow already present)
flask db upgrade # adds + backfills public_token
sudo systemctl restart gunicorn
phase35_issue_handler
Revision id phase35_issue_handler (file phase35_issue_handler_type.py). Adds to issues: handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal' and facility_handler_name/contact/notes. Backfills existing rows with a non-empty vendor_name to handler_type='vendor'. Separates WHO handles an issue (see §5 Issue + the Handler section). INFORMATION_SCHEMA checks — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase36_scheduled_insp
Revision id phase36_scheduled_insp (file phase36_scheduled_inspections.py). Creates scheduled_inspections (planned/recurring inspection assignments) and adds inspections.scheduled_inspection_id (FK → scheduled_inspections, SET NULL) that links a completed inspection back to the schedule that prompted it. See §5 ScheduledInspection and the Scheduled Inspections notes in §7/§11. INFORMATION_SCHEMA checks — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
# Add to cron (reminders — advance/due to inspector, overdue to admin/director):
# */30 * * * * curl -s -X POST https://yourdomain.com/scheduled-inspections/run \
# -d "token=YOUR_DIGEST_SECRET"
phase37_support_chat
Revision id phase37_support_chat. Creates support_chat_sessions + support_chat_messages so the customer AI support-chat is persisted (see §5 and §18 "Support Chat"). Table existence checks — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase38_support_knowledge
Revision id phase38_support_knowledge. Creates support_knowledge (admin-curated AI-chat knowledge entries). Active entries are injected into the chatbot system prompt at request time by _system_prompt_with_kb() (soft-capped at _KB_MAX_CHARS). Table existence check — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase39_area_public_token
Revision id phase39_area_public_token. Adds areas.public_token VARCHAR(48) (unguessable, unique), backfills a token for every existing area in the migration body, then creates the uq_area_public_token unique index. Backs the per-area public QR landing pages (see §5 Area.public_token and the public / facilities blueprint rows in §7). Mirrors phase34 exactly, one level down (area instead of facility). INFORMATION_SCHEMA checks — safe to re-run. No new dependency (qrcode + Pillow already present from phase34).
Deploy order:
flask db upgrade # adds + backfills areas.public_token
sudo systemctl restart gunicorn
phase41_internal_handler
Revision id phase41_internal_handler (file phase41_internal_handler_name.py). Adds issues.internal_handler_name VARCHAR(100) NULL — the free-text janitorial staff member's name used when handler_type == 'internal' (see §5 Issue + the Handler section). INFORMATION_SCHEMA column-existence check — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase42_internal_contact
Revision id phase42_internal_contact (file phase42_internal_handler_contact.py). Adds issues.internal_handler_contact VARCHAR(200) NULL — phone/email for the janitorial staff member handling the issue when handler_type == 'internal'; parallels internal_handler_name. INFORMATION_SCHEMA column-existence check — safe to re-run.
Deploy order:
flask db upgrade
sudo systemctl restart gunicorn
phase40_auditor_role
Revision id phase40_auditor_role. Adds the auditor value to the users.role ENUM (ALTER TABLE users MODIFY COLUMN role ENUM(...,'auditor') NOT NULL). This is a pure ENUM expansion (adds a value, removes/migrates nothing), so the 3-step ENUM protocol does not apply and the MODIFY is idempotent — safe to re-run. downgrade() reassigns any auditor rows to project_manager before contracting the ENUM. Backs the new Auditor role — see the auditor note in §5 and the @issue_manager_required decorator in §6.
Deploy order:
flask db upgrade # expands users.role ENUM with 'auditor'
sudo systemctl restart gunicorn
Deploy order for phases 24–32:
flask db upgrade
sudo systemctl restart gunicorn
# Add to cron:
# 0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \
# -d "token=YOUR_DIGEST_SECRET"
phase22_comment_visibility
Adds is_customer_visible BOOLEAN NOT NULL DEFAULT FALSE to issue_comments. Existing comments default to staff-only visibility. Uses INFORMATION_SCHEMA column existence check — safe to re-run.
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
- Revision ids must be ≤ 32 characters. Alembic's
alembic_version.version_numcolumn isVARCHAR(32). A longerrevision = '...'value passesflask db upgrade's DDL step but fails when Alembic writes the version row (Data too long for column 'version_num'), often leaving the schema changed but the version un-recorded. The filename may be longer (e.g.phase24_issue_created_notify_defaults.py), but therevisionid inside must be short (phase24_notify_defaults). Count before committing a new migration. CREATE INDEX IF NOT EXISTS— not supported on MySQL < 8.0.12. Always useINFORMATION_SCHEMA.STATISTICScheck first.batch_alter_table— SQLite-only workaround; do not use for MySQL migrations.- Migration deploy order: Always run
flask db upgradebefore swappingapp/__init__.pyif 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.
Admin Nav Dropdown
Admin-only tools (Users, Audit Trail, Notification Matrix, Broadcast, Devices) are consolidated into a single Admin dropdown in base.html (plain text items, no icons, no dividers — matching sibling top-level nav links). The dropdown toggle shows active when any of its endpoints is active (admin_active flag). The separate user-menu dropdown keeps its own dividers — don't replace_all on dropdown-divider across the file.
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.
Contract → Facility Cascade (filter bars and create form)
The Contract selector is always a plain HTML <select> (never a WTForms field). On change it calls GET /inspections/facilities_for_project/<project_id> and replaces the Facility <option> list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when contract_id is in the query string.
Pages using this pattern: issues/form.html (create), issues/list.html (filter bar), inspections/list.html (filter bar), scheduled_inspections/form.html (create/edit — Contract selector narrows the facility list; selected_project_id restores it on edit and on POST error).
The issues list and inspections list both accept a contract_id query param that filters the DB query to facilities belonging to that contract (facility.project_id == contract_id) and narrows the facility dropdown in the rendered HTML.
Customer role — contract filter scoping: In inspections.index() and issues.index(), the projects list passed to the template is scoped to contracts the customer is assigned to via CustomerAssignment. Non-customer roles still receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
Customer Dashboard — "Your Facilities" Panel
Rendered in dashboard.html for current_user.role == 'customer'. Uses a Bootstrap card grid instead of a table:
- Grid:
col-12 col-sm-6 col-lg-4— 3 per row on large, 2 on medium, 1 on small. - Collapse (> 9 facilities): First 9 cards are shown; a "Show all N facilities" toggle reveals the rest. Controlled by inline JS (
toggleFacilitiesbutton,VISIBLE = 9constant). - Live search (> 6 facilities): A
#facilitySearchtext input filters.facility-colcards in real time by matching against the card's full text content. The show-more bar hides while a search query is active. - Count badge: The card header always shows the total facility count as a
badge bg-secondary rounded-pill. - The JS block is only emitted when
customer_facilities|length > 9; the search input is only emitted whencustomer_facilities|length > 6.
Issue Comments — Visibility & Authorship
- Comments live in the left column of
issues/view.htmlunder the issue description, rendered as chat bubbles. - Each bubble shows: colored avatar circle (color keyed to
author.id % 7), display name, role badge (Staff / Customer),is_customer_visiblebadge (staff-only view), status-at-time badge, timestamp, and body. - Staff commenting: A hidden checkbox
name="is_customer_visible"in the Add Comment form defaults to unchecked (staff-only). Checking it marks the comment visible to customers. - Customer commenting: Only shown when
can_customer_comment = is_following or issue.reported_by == current_user.id. Customer POST bypassesIssueUpdateForm; the route setsis_customer_visible=Trueunconditionally. - Read filtering:
GET issues/viewpassesfilter_by(is_customer_visible=True)to customers; staff receive all comments.
Inspection List Filters
inspections.index() accepts five additional query params: date_from, date_to (ISO date strings), score_min, score_max (0–100 floats), inspector_id (int). Inspector filter is suppressed when the viewer has the inspector role (they always see their own only). The inspectors variable is passed to the template only for non-inspector roles so the dropdown is conditionally rendered.
Inspection List — PDF Export & Filter State Preservation
GET /inspections/export-list-pdf — same filter logic as index(), passes current filters as filter_summary string to generate_inspections_list_pdf(). Logs ACTION_EXPORT.
Filter state on back-navigation: list.html adds class insp-list-link to every View/Continue button. On click, JS saves window.location.href to sessionStorage['insp_list_back_url']. view.html reads this key on load and updates the back button href so returning from a detail view restores the previous filter state.
Issues List — ID Filter, Date Filter & PDF Export
issues.index() accepts additional query params: issue_id (exact match on Issue.id), date_from, date_to (ISO date strings applied to Issue.reported_at; date_to expanded to 23:59:59), handler_type (internal/facility/vendor), and unassigned (truthy → Issue.assigned_to IS NULL). handler_type and unassigned are both threaded through the pagination links and shared with export_list_pdf().
GET /issues/export-list-pdf — same scope + filter logic as index(), applies SLA post-filter for ?sla= param (SLA is computed in Python, not stored). Calls generate_issues_list_pdf().
Both index() and export_list_pdf() carry date_from / date_to in pagination links and the unfollow-next URL.
Inspector Performance — Excel Export
GET /reports/export/inspector-performance generates a .xlsx with two sheets:
- Performance Summary — all inspector KPIs, color-coded cells, totals row
- Inspection Detail — individual inspection records for the period
Accepts start, end, inspector_id query params. Logs an EXPORT audit action. Uses openpyxl.
Dashboard — Grouped Sections
The dashboard cards are organised into two labelled sections separated by a divider rule:
Inspections section (all roles see first 2; staff see all 4):
- Today's Inspections — links to
inspections.indexfiltered by today - Submitted Today — links to
inspections.indexwithstatus=completed+ today's date - Stale In-Progress — inspections with
status=in_progressANDinspection_date < now - 24h; links toinspections.index?status=in_progress - Pending Follow-ups — inspections with
follow_up_required=True; links toinspections.index?status=follow_up
Issues section (customers see first 3; staff see all 5):
- Open Issues — split into THREE cards (all roles), one per handler from
handler_breakdown: Open · Janitorial (handler_type=internal, bg-primary), Open · Facility Staff (handler_type=facility, bg-info), Open · Vendor (handler_type=vendor, bg-warning). Each is a full clickable card linking toissues.index?status=open&handler_type=.... (The single total Open-Issues card with severity C/H/M/L badges was replaced by these three;severity_breakdownis still passed but no longer rendered on the dashboard.) - Issues Opened Today —
date_from=today&date_to=today, with a compact "Handled by" chip split fromopened_today_handler. - Resolved Today —
status=resolved+ today's date range - Pending Verification —
status=pending_verification - Unassigned Open —
status=open&unassigned=1, with a compact "Handled by" chip split fromunassigned_handler.
"Handled by" splits (phase35+): the three Open-Issue cards are dedicated cards; cards 2 and 5 use the compact handler_chips(bd, base) Jinja macro at the top of dashboard.html — full-text chips ("Janitorial N / Facility N / Vendor N") linking to issues.index with the card's own filter (base) plus handler_type=. All breakdowns come from _handler_split(list) over already-loaded issue lists (open_issues_all, opened_today_all, unassigned_all) — no extra queries. Cards with inner links are not wrapped in an outer anchor (nested <a> is invalid). issues.index gained an unassigned=1 filter (Issue.assigned_to.is_(None)), threaded through the list pagination links, so the Unassigned card and its chips link precisely.
Each card has a subtitle line (or the handler split) explaining what it counts. Section dividers use d-flex align-items-center gap-2 with a <div style="flex:1;height:1px;background:#e2e8f0;"> rule.
Inspector Activity table follows the cards for admin/director/PM: all active inspectors, today's completed inspection count per inspector, progress bar scaled to max_count. Green row highlight if count > 0.
Reports — Navigation & New Pages
The Reports main-nav item is positioned second (right after Dashboard). Scheduled Reports was removed from the main nav and is now a sub-nav tab inside Reports (visible to admin/director/PM).
All report pages include {% include 'reports/_subnav.html' %} as the first element inside {% block content %}. The sub-nav tab visibility is role-gated:
| Tab | Roles |
|---|---|
| Overview & Trends | All |
| Issues Aging | All |
| SLA Compliance | All |
| Follow-up Closure | admin, director, project_manager |
| Inspector Performance | admin, director |
| Scheduled Reports | admin, director, project_manager |
Reports — Overview & Trends: "Avg Score by Facility" Contract filter
The Avg Score by Facility card (chart) and the Facility Score Comparison table on reports/index.html share a Contract <select> (#scoreContractFilter) in the chart card header. It is client-side only: reports.index() attaches project_id + contract name to each facility_scores row and passes score_contracts (distinct (project_id, name) present, 0/"No Contract" for unassigned). Selecting a contract filters both the Chart.js bars (renderFacilityChart(pid) mutates the existing chart) and the table rows (.facility-score-row[data-project-id]); default "All Contracts" shows everything. It does not reload the page or affect the top KPIs — only this section. Contracts shown are already role-scoped (customers/inspectors see only theirs).
Reports — Phase R1: Issues Aging (/reports/issues-aging)
Loads all non-resolved issues scoped by role, groups into five age buckets (<24h, 1–3 days, 3–7 days, 1–4 weeks, >4 weeks). SLA status computed per-issue via sla_status(). Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
Excel export: GET /reports/export/issues-aging — one sheet, color-coded severity and SLA columns.
Helper: _load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter) — extracted so both the HTML route and the Excel export share identical query logic.
Reports — Phase R2: SLA Compliance (/reports/sla-compliance)
Loads resolved issues in the date range, computes within_sla() per issue (compares elapsed hours to SLA_HOURS[severity]). Produces:
overall_pct— org/scope-wide compliance %by_severity— dict withtotal,met,pct,sla_hoursper severity tierby_facility— list sorted by compliance % descending
Helper: _sla_within(issue) — used by both the HTML route and the Excel export.
Excel export: GET /reports/export/sla-compliance — 2 sheets: By Severity (with totals row) and By Facility.
Reports — Phase R3: Follow-up Closure (/reports/followup-closure)
@supervisor_required. Loads inspections with follow_up_required=True in date range. Determines which have been re-inspected via a separate SELECT parent_inspection_id FROM inspections WHERE parent_inspection_id IN (...) query — avoids iterating the dynamic follow_ups relationship.
Annotates each inspection with insp._has_followup = insp.id in followed_up_ids (transient Python attribute, not an ORM column).
Excel export: GET /reports/export/followup-closure — one sheet with color-coded "Followed Up?" column (green/red).
Reports — Phase R4: Customer Facility PDF Summary
GET /reports/facility/<id>/summary-pdf — available to all roles with facility access (customer scope enforced). Accepts days param (30/60/90/180/365; default 90). Calls generate_facility_summary_pdf() from pdf_export.py. Downloads directly as a PDF attachment.
A PDF Summary button was added to reports/scorecard.html alongside the existing Full Report and period selector buttons.
Support Chat — Customer UX
GET /support/chat — customer only. Renders:
- Greeting message with
current_user.display_name(injected viavar userName = {{ current_user.display_name | tojson }}— usetojsonnot inline interpolation to prevent XSS/quote breaks). - FAQ quick-reply chips: text stored in
data-faq="..."HTML attribute (HTML-escaped with| e), read in JS viabtn.dataset.faq. Never use| tojsonin anonclick=""attribute — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the<script>tag. The FAQ section stays visible for the whole session (phase37) — it is NOT hidden after the first message, and chips are not disabled after clicking (reusable throughout). - Chat history kept client-side in
let history = [], sent with each AJAXPOST /support/chat/message. Server caps at last 20 turns. - Persistence (phase37): conversations are saved to
support_chat_sessions/support_chat_messages. The page reloads the customer's most recent session into the window (continuity) unless?new=1.let sessionId(seeded fromchat_session_id) is sent with each message and updated from the response; the server persists both turns. Header buttons: History (/support/my-conversations) and New (/support/chat?new=1). Staff can review any customer's chats read-only at/support/admin/conversations. Both detail views{% include 'support/_transcript.html' %}. - If
GROQ_API_KEYis absent, input is disabled and a fallback "Submit to Support" link is shown (the reply is still persisted). - "Submit to Support" modal POSTs to
POST /support/tickets; subject pre-filled from last user message in history.
Inspection Execute Page — UX Patterns
- Photo upload-on-select:
uploadPhotoField(input)fires immediately on<input type="file">change. XHR toPOST /<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:
#flagIssuePanelBootstrap offcanvas contains the flag-issue form. On submit,saveDraft()fires first, then the form is sent viafetch()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.#autoSaveStatusin the footer shows the last-saved timestamp. - Progress indicator: Counts answered non-zero rating fields vs. total; updates
#progressLabelin the footer on every change. - Scroll restore:
window.scrollYsaved tosessionStorageonbeforeunload; restored onload.
19. Infrastructure
Gunicorn
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
ProxyFix (reverse-proxy awareness)
create_app() wraps app.wsgi_app in werkzeug.middleware.proxy_fix.ProxyFix(x_for=1, x_proto=1, x_host=1). Nginx terminates TLS and forwards over loopback, so without this every request's remote_addr is 127.0.0.1. That would collapse all Flask-Limiter keys into one shared bucket (rate limits become global instead of per-client) and make url_for(_external=True) emit http:// links. x_for=1 trusts exactly one proxy hop (our own Nginx) — do not increase it unless another trusted proxy is added in front, or clients can spoof X-Forwarded-For and defeat rate limiting.
Recommended Cron Schedule
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"
0 8 * * * curl -s -X POST https://your-domain.com/notifications/check-score-trends \
-d "token=SECRET"
*/30 * * * * curl -s -X POST https://your-domain.com/scheduled-inspections/run \
-d "token=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 all documented fields |
iPad reads photo_path + mobile_photo_paths into photoServerPaths. Phase A–E added result_notes, verified_at, verification_note, reported_by_name, area_name, assigned_to_name. Omitting any field silently breaks the corresponding iPad display. |
| 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. |
| 60 | flag_issue offcanvas form must include <input type="hidden" name="facility_id"> |
IssueForm.facility_id has DataRequired(). The hand-written offcanvas form in execute.html is not rendered by WTForms, so it must explicitly send facility_id. Without it, form.validate_on_submit() silently returns False, the server responds 200 OK with the flag_issue.html template, and the JS treats res.ok as success — no issue is ever saved. Fix: <input type="hidden" name="facility_id" value="{{ inspection.facility_id }}"> inside #flagIssueForm. |
| 61 | Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field | The "Log New Issue" form (issues/form.html) and both filter bars (issues/list.html, inspections/list.html) use a plain HTML <select id="...contract..."> that triggers an AJAX call to GET /inspections/facilities_for_project/<id> on change, repopulating the facility dropdown. IssueForm.facility_id.choices is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives selected_project_id from the submitted facility_id's project_id and passes it to the template so JS can restore both selectors. |
| 62 | issue.resolved_facility.project and inspection.facility.project give the contract |
Project.facilities declares backref='project', so facility.project is a direct ORM attribute (not a dynamic query). Guard all template accesses: ins.facility.project.name if ins.facility and ins.facility.project else '—'. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a contract_id query param that pre-filters the facility dropdown server-side. |
| 63 | Customer Contract filter scoped to assigned contracts only | inspections.index() and issues.index() build the projects list differently for customer role: query CustomerAssignment.query.filter_by(user_id=current_user.id) to get assigned project_id values, then filter Project to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in inspections.start(). |
| 64 | Invitation/reset email From must be a mail-server-authorized identity; never a bare noreply@<full-host> |
Both _send_invite_email (customers.py) and _send_password_reset_email (auth.py) take an optional base_url (call sites pass request.host_url) and build setup_link/reset_link from effective_base, so the link always points at the host the user is on. The From is where deliverability lives: a per-host noreply@jqc.ltservicesinc.com (subdomain, wrong local part) was accepted by the relay then silently dropped by SPF/DMARC — reset/invite mail never arrived while notification mail (which used MAIL_DEFAULT_SENDER) did. Confirmed July 2026 via SMTP A/B test. Current behaviour: reset password sends from the fixed authenticated MAIL_DEFAULT_SENDER (fallback MAIL_USERNAME); customer invite sends from branded_sender(effective_base) (see §8 mail_utils.py), a (display_name, address) tuple. The display name is always per-domain (BRAND_NAMES), but the From address is branded only for the authenticated domain and any SENDER_AUTHORIZED_DOMAINS — all other domains keep the authenticated address so they always deliver. This is the deliverable default: From: "Gov Services QC" <jqc.noreply@ltservicesinc.com> with zero DNS. To brand the actual address for another domain, set up its SPF include + DKIM, then add it to SENDER_AUTHORIZED_DOMAINS. Never brand an address for a domain lacking SPF/DKIM (accepted-then-dropped, the failure above), and never reintroduce noreply@<full-host>. |
| 65 | Customer "Your Facilities" uses a card grid, not a table | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is VISIBLE = 9; the search input threshold is > 6. Both thresholds live as JS/Jinja constants in dashboard.html and can be adjusted together if needed. |
| 66 | FAQ chip text must use data-faq attribute, not onclick with | tojson |
| tojson emits "text" (double-quoted) inside onclick="..." (also double-quoted), breaking HTML parsing and silently truncating the <script> block. Use data-faq="{{ text | e }}" and read via btn.dataset.faq in JS. |
| 67 | display_name in JS must use | tojson, not inline Jinja interpolation |
"Hi {{ name }}" in a JS string literal breaks if name contains " or \. Use var name = {{ name | tojson }}; then concatenate. |
| 68 | Support ticket customer replies revert status from answered → open |
When a customer posts a follow-up on an answered ticket, the route sets ticket.status = 'open' so admins see it in their open queue. Admin must manually close or re-answer. |
| 69 | Customer issue create: assigned_to field hidden, facility_id scoped to get_customer_scope() |
issues.create() detects role == 'customer', scopes facilities to the customer's assigned set, sets staff = [] for the assigned_to dropdown, and hides the field in form.html. IssueForm.facility_id.choices must still include all active facilities so POST validation passes. |
| 70 | notify() does NOT commit — caller must db.session.commit() after all notify() calls |
notify() adds a Notification row to the session but leaves the commit to the caller. The support helpers (_notify_admins_new_ticket, _notify_customer_reply, _notify_admins_customer_reply) each call db.session.commit() after the notify() loop. |
| 71 | ProxyFix must wrap app.wsgi_app in create_app() |
Behind Nginx, remote_addr is 127.0.0.1 for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). x_for=1 trusts exactly one proxy hop. See §19. |
| 72 | Device registration has exactly ONE implementation — register_device() in app/api/auth.py → api_device_tokens |
A second POST /api/v1/devices/register (app/api/devices.py + DeviceRegistration model) was removed July 2026. It was shadowed by the api_auth route at routing time and queried the dropped device_registrations table. Do not reintroduce a competing device model or duplicate register route. |
| 73 | Per-contract recipients are dispatched ONLY inside notify_by_matrix() — never add a parallel path |
_notify_contract_recipients() runs after role + global-custom-email routing and shares the notified / sent_emails dedup sets. Any new event that should reach contract recipients must go through notify_by_matrix() (passing facility_id, or an issue_id/inspection_id that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
| 74 | The public blueprint (/f/*) is login-free — keep it occupant-safe |
Pages are addressed by unguessable public_token (never facility id), 404 on inactive/unknown facilities, and expose only aggregate quality data: the overall rating, inspection/issue COUNTS, the score trend, and a Recent Inspections list showing each inspection's date + quality label (July 2026 — _recent_rows() shapes these). _recent_rows() deliberately does not put the raw score in the payload, so the percentage cannot leak into the rendered page; _rating_label(None) yields "Not yet rated" so unscored inspections render safely. Still never: raw per-inspection score percentages, issue descriptions, inspector names, checklist/template names, per-checklist-item scores, severity/SLA detail, or any other facility's data. The report POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and idempotency-guarded (_recent_duplicate_report() — an identical public report for the same facility/area within DUPLICATE_REPORT_WINDOW_SECONDS=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE _save_report_photos() to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with reported_by=NULL, severity='medium', and routed through notify_by_matrix('issue_created', facility_id=...). Photos: the report form accepts up to 5 photos (PublicIssueReportForm.photos, a MultipleFileField); _save_report_photos() in public.py saves them via the shared magic-byte-validated _save_photo() (cap MAX_REPORT_PHOTOS=5) and stores the first in Issue.photo_path, the rest in Issue.mobile_photo_paths — never result_photos (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse render_template('base.html') here — the public page is a standalone template with no authenticated nav. |
| 75 | Email is stored lowercased; look it up case-insensitively | User/customer email is normalized to .strip().lower() at every write site (auth.py profile/create/edit, customers.py invite/edit). Forgot-password lookup uses db.func.lower(User.email) == input so a mixed-case legacy row still matches — a plain filter_by(email=...) silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
| 76 | Transactional email From must be an SMTP-authorized identity, per-domain branding via display name only |
Reset-password sends from MAIL_DEFAULT_SENDER; customer invite sends from branded_sender() = (per-domain display name, authorized address). A per-host noreply@<subdomain> sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 mail_utils.py. |
| 77 | GET /api/v1/scheduled-inspections is inspector-scoped by inspector_id, admin/director/PM see all |
New app/api/scheduled.py blueprint. Register in app/api/__init__.py AND csrf.exempt(_api_scheduled_bp) in app/__init__.py — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in routes/scheduled_inspections.py). |
| 78 | PATCH /api/v1/issues/<id>/handler allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction |
The iPad lets the assigned inspector set "Handled By" from the field, scoped via get_inspector_scope() (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. _issue_payload() must keep returning all handler fields (handler_type, handler_label, facility_handler_*, vendor_*, internal_handler_name, internal_handler_contact) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
| 79 | auditor = project_manager access + issue management, minus delete — keep the two decorators distinct |
Auditor is added to @project_manager_required (PM baseline) and to every project_manager role check in routes/templates. Its extra issue powers (verify/bulk-verify/verification-queue) go through the separate @issue_manager_required (admin/director/auditor). Issue delete stays @supervisor_required — never add auditor there. When adding a new PM-level gate, include auditor; when adding a director-only or delete-level gate, do not. The three issue delete template gates (spaced ['admin', 'director'] in issues/list.html + issues/view.html) are deliberately left without auditor. Auditor is also in the _ALLOWED_ROLES set of every app/api/* module — a new API blueprint's _ALLOWED_ROLES must include auditor for PM parity. |
| 80 | Assignee dropdowns are director/inspector/auditor (admin removed, auditor added) |
The issue/inspection assignee <select>s query User.role.in_([...]) — admin was removed and auditor added (the inspection flag-issue list also keeps project_manager). These lists control who can be assigned, distinct from who can edit. The issue-update route (issues.view) defensively appends any current assigned_to who is not in the set (e.g. a legacy admin assignment) to form.assigned_to.choices so saving the form never silently unassigns them. Do not remove that guard. |
| 86 | A second FK from a table to users breaks any relationship that didn't pin foreign_keys |
Adding inspections.follow_up_requested_by (phase46) made User.inspections ambiguous — AmbiguousForeignKeysError, raised at first ORM use, not at import, so the app starts fine and then every request 500s. User.inspections now pins foreign_keys='Inspection.inspector_id'. Check existing relationships before adding another FK to users from a table that already has one. |
| 83 | A bad scheduled_inspection_id must NEVER fail the inspection submission |
_resolve_schedule() in app/api/inspections.py drops an unknown or foreign link and logs a warning instead of returning 404/403. The app is offline-first: a completed inspection can sit in the outbox for days, during which the schedule may be deleted, reassigned, or rolled forward. Erroring would burn the 5 sync retries and permanently strand that inspection and its photos on the device. A missed fulfil is fixable from the web; a stranded submission is not. The ownership check still refuses to link a foreign schedule (one inspector must not fulfil another's) — it just accepts the inspection anyway. |
| 82 | A schedule's recurrence columns must be CLEARED when they don't apply to the chosen frequency | _apply_recurrence() in routes/scheduled_inspections.py is the single write path for frequency + weekdays/month_mode/day_of_month/nth_week/nth_weekday, and it NULLs the blocks that don't apply. Setting sched.frequency directly (as create/edit used to) leaves stale settings behind — a weekly→monthly switch would keep weekdays and recurrence_label would lie. The hidden form blocks still POST their values, so client-side hiding is not enough. |
| 84 | "Instructions" is a LABEL over notes — never rename the field, attribute, column or API key |
ScheduledInspectionForm.notes renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays notes (api/scheduled.py::_scheduled_payload), which is what APIScheduledInspection.notes decodes into LocalScheduledInspection.notes; the iPad exposes it through a computed instructions accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is try?-decoded, so it would fail to nil rather than throwing. |
| 85 | next_due_date is mutable state, end_date is a fixed boundary — never conflate them |
fulfill() rewrites next_due_date after every completed inspection; end_date is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — form.next_due_date.label.text is set to "Start Date" in create() and "Next Due Date" in edit(). Do not rename the next_due_date column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
| 87 | Never write role == 'inspector' — use user.is_inspector (User.INSPECTOR_ROLES) |
phase49 added external_inspector, which must behave as an inspector everywhere. An equality check silently drops it into the privileged branch of every if inspector: scope … else: org-wide block — i.e. a third-party inspector would see every contract in the system. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving == 'inspector' literals are the matrix docstring, the MATRIX_DEFAULTS mirror comprehension, and the default-checked box in admin/broadcast.html. Query-level checks use User.role.in_(User.INSPECTOR_ROLES) (never filter_by(role='inspector')). A new app/api/* blueprint's _ALLOWED_ROLES must include external_inspector, same as rule 79 requires for auditor. |
| 88 | app/enrollment/ writes no DB row and has exactly ONE read — keep the vertical slice sealed |
The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in ENROLLMENT_DIR and owns its own templates. The single permitted model access is mailer._admin_recipients() reading active admin users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST create Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against _ID_RE before every open (path traversal). See §24. |
| 81 | Photo timestamp/geo overlay is burned at UPLOAD, never on PATCH /issues/<id>/photos |
That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (storage.save() mints a NEW uuid key, and §22 requires key == DB path), and would risk a double burn since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in POST /photos/upload, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
21. Change Philosophy
- Surgical, additive patches — smallest possible change to achieve the goal
- Preserve all routes, function names, variable names unless explicitly directed otherwise
- Never remove existing functionality unless explicitly directed
- Log all create/update/delete actions via
log_action() - Migration existence checks — all migrations safe to re-run
- Full file contents for 1–3 file changes; deployment map for larger changesets
- Explicit deploy instructions — migration steps separated from code steps
- Root cause analysis on errors — never apply temporary workarounds
22. Object Storage Migration (R2)
Goal: move photo files off the server's local disk (app/static/uploads/) to Cloudflare R2 (S3-compatible, $0 egress) so storage expands without touching the server. The database is NOT the bottleneck — rows are tiny; PDFs are streamed (BytesIO), never written to disk. Only photos accumulate.
No schema change. The DB already stores a relative path string (uploads/issue_photos/abc.jpg). That exact string becomes the object key in R2, so rows never change and media_url() resolves the same key on either backend.
Serving decision: private bucket + presigned GET URLs (TTL 24h). This is more private than today (static files are served unauthenticated). media_url() is the single seam — switching to a public custom-domain CDN URL later is a one-function change.
Complete photo-path inventory (the migration MUST cover all 5 — missing one loses those photos from the UI even if the file exists):
Issue.photo_path(string)Issue.mobile_photo_paths[](JSON list — iPad evidence)Issue.result_photos[](JSON list — web resolution photos)Inspection.form_data{}— anyuploads/...string (image fields; walk nested)InspectionResult.photo_path(string — per checklist-item result photo)Inspectionitself has nophoto_pathcolumn (thephoto_pathat inspection.py:103 belongs toInspectionResult, notInspection).- Signatures are inline
data:base64 in the DB, not files → correctly ignored.
Zero-loss invariants (non-negotiable):
- Object key == the relative path string already in the DB. Never rewrite DB paths.
- Copy, never move. The migration only uploads. Local
static/uploads/is untouched through cutover and a 30-day safety window. - Per-file MD5 + size verification (local hash vs R2
head_object) — a file counts as migrated only when it matches. - Cutover is gated: only flip
STORAGE_BACKEND=s3whenverified == referenced-present(from the Phase 0 baseline) with zero mismatches. - S3 backend falls back to the local file if a key is absent (transition safety).
- Rollback = one env var (
STORAGE_BACKEND=local+ restart). Local files never left disk. - Magic-byte validation stays in the caller, before
storage.save(). media_url()is the ONLY place a photo URL is built server-side — no bareurl_for('static', filename=<photo>)anywhere after Phase 1.
Phase 0 — Audit (READ-ONLY) — ✅ delivered
scripts/audit_photos.py. Reads all 5 sources + disk, reconciles, writes a JSON baseline. Writes nothing to the DB or uploads tree.
- Script delivered (
scripts/audit_photos.py). - Run on production:
python scripts/audit_photos.py --report /home/jqc/photo_audit_baseline.json - Record the baseline number: "N photos must still resolve after cutover."
- Investigate any
missing_on_diskentries — these are PRE-EXISTING broken references, surfaced now so they can't be blamed on the move.
Phase 1 — Storage abstraction + local backend (NO-OP, safe to deploy) — ✅ delivered
app/utils/storage.py— interface:save(file_obj, subfolder) -> key,read(key) -> bytes,delete(key),exists(key) -> bool,abs_local_path(key),media_url(key) -> str. Backend chosen bySTORAGE_BACKEND, one instance cached inapp.extensions.LocalBackend= byte-for-byte current behavior (static/uploadswrite;media_url=url_for('static', filename=key);abs_local_path=static_folder/key).- Config:
STORAGE_BACKEND=local|s3(defaultlocal) inconfig.py. - Routed
_save_photo()(inspections.py) +api/photos.py::upload_photothroughstorage.save()(magic-byte / ext validation stays in the callers). media_urlregistered as a Jinja global inapp/__init__.py; swapped all 8 photo render sites:issues/view.html(×6: primary, mobile, result),inspections/view.html(×1: form image),inspections/execute.html(×1).- Deploy with
STORAGE_BACKEND=local→ identical behavior; regression check that web + iPad photos still load and new uploads still save. - Deferred to Phase 2 (not yet routed through storage): photo deletes (issues.py ~1013, inspections.py ~1340) and PDF-export image reads (
utils/pdf_export.py). These still hit local disk directly — fine whilelocal, must be routed before thes3flip. Bug to fix when routing: the inspection-delete cleanup buildsos.path.join(UPLOAD_FOLDER, '..', 'static', rel_path)which normalizes toapp/static/static/...(doublestatic) and never deletes — routing it throughstorage.delete(key)fixes it. The issue-delete path (root_path + 'static' + rel_path) is already correct.
Phase 2 — R2 backend (build, do NOT flip yet)
2a — delivered:
boto3>=1.34added torequirements.txt(imported lazily inS3Backend— alocaldeploy doesn't need it installed).- R2 config keys in
config.py:R2_ENDPOINT_URL,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET,R2_PRESIGN_TTL(default 86400),R2_MEDIA_FALLBACK(default false). S3Backendinstorage.py: private bucket;save→put_object(ContentType from ext);media_url→ presigned GET (TTL);read→get_object;exists/_head→head_object;delete→delete_object. Client cached inapp.extensions,region_name='auto', SigV4.- Transition fallback:
read()falls back to the local copy on a missing object;media_url()falls back to a local static URL only whenR2_MEDIA_FALLBACK=true(off by default — avoids a HEAD per image; cutover is gated on full verification anyway). - Routed photo deletes (issues.py, inspections.py) →
storage.delete(key); fixed the inspection-delete double-staticbug in the process.
2b — delivered:
- PDF-export image reads routed through storage via
storage.materialize_to_dir(keys). Local backend returns the real static folder (no-op); s3 downloads the referenced keys to a temp dir mirroring thekeylayout. The two entry points (generate_inspection_pdf— keys from_collect_media_keys(form_data);generate_issue_pdf—photo_path+mobile_photo_paths+result_photos) pointstatic_folderat it and clean up infinally. All render sites (_form_fields_section,_photo_grid,_compress_image) are unchanged — they still doos.path.join(static_folder, key), which now resolves under the temp dir on s3. Only the inspection + issue PDFs embed photos; list/scheduled/facility PDFs don't takestatic_folder.
Operator setup (before flip, not code):
- Create R2 bucket (e.g.
jqc-media), US region; create an R2 API token → Access Key + Secret + endpoint. .env:R2_ENDPOINT_URL,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET=jqc-media,R2_PRESIGN_TTL=86400. KeepSTORAGE_BACKEND=localfor now.pip install boto3into the venv.
Phase 3 — Verified migration sync — ✅ script delivered
scripts/migrate_photos_to_r2.py. Copy-only (never deletes local, never touches the DB), idempotent, resumable, checksummed. Reads R2 creds from config — run it while still on STORAGE_BACKEND=local.
- Script delivered. Per file: MD5 + size → skip if R2 object already matches (resumable) → else single-part
put_object(so ETag == MD5) → re-head_objectand assert ETag + size match. Uploads every file understatic/uploads/(orphans included). JSON log of all failures. Exit 0 only when every file verifies (0 mismatches, 0 errors). python scripts/migrate_photos_to_r2.py --dry-run(review plan).python scripts/migrate_photos_to_r2.py— bulk sync (can run whilelocalis live; only writes to R2).- Re-run immediately before cutover to catch the delta (idempotent — skips already-verified).
- Gate: script exits 0 → every local file verified in R2. Cross-check
verified >= referenced-presentfrom the Phase 0 baseline. Only then flip in Phase 4.
Phase 4 — Cutover + iOS
4a — server photo URLs — ✅ delivered:
storage.media_url(key, external=False)—external=Trueyields an absolute URL for off-origin (iPad) consumers: local backend usesurl_for('static', ..., _external=True); s3 returns the (already absolute) presigned URL. Templates call withexternal=False→ unchanged relative/static/URLs.- Issue payload (
api/issues.py_issue_payload) addsphoto_urls(absolute; order =[photo_path] + mobile_photo_paths, matching the iPad's evidence merge) andresult_photo_urls. Relative keys stay as keys (they're still submission values). Helper_photo_urls(). - Inspection detail payload (
api/inspections.py) addsform_media={field_id: absolute_url}foruploads/...image field values. Helper_media(). - These are additive — the iPad ignores them until 4b ships, and on
localthey're just absolute static URLs, so nothing breaks.
4b — iOS: consume the URLs — ✅ delivered:
APIAssignedIssuegainsphotoUrls+resultPhotoUrls;APIIssueDetailgainsresultPhotoUrls;APIInspectionSummarygainsformMedia({fieldId: url}) + a derivedmediaURLByPath({path: url}).LocalIssuestores parallelphotoServerUrls/resultPhotoServerUrls(JSON-backed), mapped inSyncManager.pullAssignedIssues(both branches) andIssuesView.refreshStatusFromServer.ServerConfig.mediaURL(absolute:path:)resolver — prefers the absolute URL, falls back tocurrent + "/static/" + path. Wired at all display sites:IssuesViewevidence + result (index-paired with the URL arrays),InspectionHistoryViewPhotoThumbnailView(via amediaURLByPathSwiftUI environment injected on the read-only grid — avoids threading field IDs through the grid).- Backward-compatible: empty URL arrays (older server) → resolver builds the
/static/URL as before.
Operator cutover runbook (after 4b ships + Phase 3 sync exits clean):
- CSP: the web
img-srcmust allow the R2 host or the browser blocks presigned image loads. Handled inapp/__init__.pyset_security_headers— the R2 endpoint host is derived fromR2_ENDPOINT_URLand appended toimg-srcautomatically when configured (local backend unaffected). If you use a custom R2 domain for presigned URLs, add that host too. - Confirm
python scripts/migrate_photos_to_r2.pyprints "✅ SAFE" (0 mismatches) and verified count ≥ Phase 0 baseline present-count. - Maintenance window: run the sync once more (delta) → set
STORAGE_BACKEND=s3in.env→systemctl restart janitorial-qc. - Smoke test: existing web issue/inspection photos load; existing iPad issue photos load; a new upload from web and from iPad lands in R2 and renders; generate an inspection PDF + issue PDF with photos.
- Keep
static/uploads/for 30 days as backup (untouched). Snapshot, then reclaim disk as a separate, deliberate step.
Rollback (any phase after cutover)
STORAGE_BACKEND=local→ restart. Instant revert; local files were never touched.
23. Photo Capture-Time / Geo Overlay
Goal: evidence photos carry a visible, tamper-evident record of when and where they were taken. Implemented in app/utils/photo_stamp.py, applied in POST /api/v1/photos/upload.
Why upload-time and not PATCH /issues/<id>/photos (rule 81): that PATCH only receives path strings — the bytes are already stored and it carries no capture metadata. Stamping there would require a read-modify-write per key, a new overwrite-in-place storage primitive (storage.save() mints a new uuid key; §22 requires key == DB path), and would risk a double burn on retry since the endpoint is intentionally idempotent (rule 45). At upload the raw bytes and camera EXIF are in hand and exactly one already-stamped object is written.
Metadata resolution order
- Client fields —
captured_at(ISO-8601, offsets andZaccepted),latitude,longitudemultipart form fields. Preferred: the app is offline-first, so a photo taken at 09:14 may not sync until 16:00 — only the client knows the true capture moment. - EXIF —
DateTimeOriginal→DateTimeDigitized→DateTime; GPS from the GPS IFD (DMS rationals → signed decimal, honouring N/S/E/W refs). - Server receipt time — last resort, no geo.
resolve_metadata() returns (dt, lat, lng, source) where source ∈ client|exif|server; it is echoed back as capture_source in the response and logged, so you can tell how much to trust a given stamp.
Rendering
- Translucent black bar across the bottom; line 1
YYYY-MM-DD HH:MM:SS EDT, line 2lat, lng(omitted when unknown). - Font/padding scale off the image's short edge, so portrait and landscape look the same. TrueType is probed at the usual Linux/Windows paths with a graceful fall back to Pillow's default.
- White text with a 1px dark outline stays legible over bright surfaces.
ImageOps.exif_transpose()runs before drawing — the re-encode drops EXIF, so without it an iPhone photo would come out visibly rotated and the bar would land on the wrong edge.- JPEG (q88) and PNG are stamped; GIF and anything else passes through untouched rather than risking a broken re-encode.
Hard guarantees
- Never lose a photo. Every failure path (corrupt bytes, unsupported format, missing Pillow, font problems) returns the ORIGINAL bytes with
stamped: Falseand logs a warning — it never raises. - No storage/schema change.
stamp_file_storage()returns awerkzeugFileStoragewith the same filename/content-type, sostorage.save()derives the same key and both thelocalands3backends work unchanged. - Toggle with
PHOTO_STAMP_ENABLED=false(defaulttrue) to store raw uploads.
Not covered (deliberate)
- Web-form uploads (
_save_photoinroutes/inspections.py) are not stamped — browsers rarely supply reliable capture/GPS metadata. The helper is reusable if that changes. - Only the stamped image is stored; no pristine original is retained. Since the burn happens before the first write, nothing stored is ever destroyed.
- EXIF is not re-written into the output (the overlay is the record). Add it here if a machine-readable copy is ever needed.
24. Enrollment Form (/enrollment)
A customer-facing onboarding intake reproducing the printed JQC Enrollment Form, held deliberately apart from the rest of the application. It is the one feature in the tree that owns its whole vertical slice.
app/enrollment/
├── __init__.py register_enrollment(app) + the separation contract
├── schema.py the form AS DATA — single source of truth
├── storage.py JSON-file persistence (no model, no migration)
├── routes.py public form + admin inbox
└── templates/enrollment/
├── form.html standalone public page (no base.html)
├── submitted.html thank-you + reference number
├── admin_list.html extends base.html
└── admin_detail.html extends base.html
Separation contract — keep this true
- No
app.modelsimport, nothing written to the database. Enrollment happens before any contract, facility or user exists, so there is nothing to key a row against. Deleting the package would remove the routes and nothing else. - No migration, no model, no notification-matrix event, no API/iPad surface.
- Its own
template_folder— enrollment markup never mixes intoapp/templates/. - The only shared code it uses is what it should not reinvent: the app factory, Flask-WTF CSRF, the rate limiter,
@admin_required.
If it ever needs to create the accounts it describes, do that as a separate explicit admin action that reads a stored submission. Do not let the public form reach into the models.
Form flow (people first, then the matrix)
The printed form had six fixed seats (Admin/Director + Inspector 1–5) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that:
The header collects Project Name, Request by, Requester email (required — the confirmation goes there) and Date Requested. The printed sheet's blank "for office use" block is not rendered on the web form — a customer cannot fill it in; those fields still exist and are filled by staff on the admin detail page.
- Step 1 — the people. Free-form rows, each with a role dropdown (
schema.ROLES: Admin / Director / Auditor / Inspector / External Inspector), name, job title, email. Starts with one row defaulted toDEFAULT_FIRST_ROLE; "Add another person" appends more, capped atMAX_PEOPLE(25). The last row cannot be removed. - Step 2 — the task matrix, with one column per person from Step 1, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name).
- Step 3 — mobile app, likewise one column per person.
A "Recommendation selection" button applies schema.recommendation_map() per person's role — admin-side roles get the Admin/Director column of the old table, inspector roles the Inspectors column — after which any box can be changed. The RECOMMENDATION table itself is no longer rendered; schema.RECOMMENDATION remains the authority behind the button. The button deliberately leaves Step 3 alone — who carries a tablet is not something a preset can guess.
Field naming — the client index and the stored key are independent. The browser names fields person_<n>_*, task_<ref>_person_<n>, mobile_person_<n> where <n> is a monotonic row counter (gaps appear when rows are removed). The server discovers which indexes were actually posted (_PERSON_FIELD_RE, never a client-supplied count), drops entirely blank rows, and re-keys people by position into p1, p2, … for storage. So a customer deleting a middle row cannot shift anyone's answers, and stored matrix keys are always dense.
Admin-only tasks are enforced server-side. task_applies() gates ref 10 (Search/Export Reports) to ADMIN_ROLES; the POST parser only reads cells the person's role offers, so a crafted POST cannot record an admin-only task against an inspector — verified.
schema.py is the source of truth
ROLES, ADMIN_ROLES, TASKS (10 rows; ref 10 is admin_only), RECOMMENDATION, OFFICE_FIELDS, STATUSES. The public template renders from it and hands it to the page as JSON (ROLES, TASKS, ADMIN_ROLES, recommendation_map()), the POST parser iterates it, and the admin views re-render stored answers through it — so adding a task row or a role is a one-line edit with no template, JS or parser change.
Legacy submissions. Files stored in the original fixed-seat format are never rewritten; schema.people_of() / cell() / wants_mobile() normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file.
Emails on submission
Two messages, both fired after storage.save(), both on a background thread via _dispatch() (rule 14), both From branded_sender() (rules 64/76):
| To | Function | Template | Contents |
|---|---|---|---|
| The requester | send_confirmation() |
email_confirmation.html |
Reference, project, the people table. Corrections are directed to schema.CORRECTIONS_EMAIL, not a reply — the From is an unmonitored no-reply. |
| JQC admins | send_admin_notification() |
email_admin_notice.html |
Project, requester, reference, people table, customer notes, and an "Open in JQC" deep link to /enrollment/admin/<id> built from the submitting host (so a multi-domain deployment links to the host actually in use). |
Recipients come from _admin_recipients(): active admin accounts, plus any addresses in the optional ENROLLMENT_NOTIFY_EMAILS config (comma-separated) for people who should be told but hold no JQC login. Deduplicated case-insensitively. Directors, inspectors and inactive admins are excluded — verified.
Neither email can cost a customer their enrollment. Every failure path is caught and logged: no MAIL_SERVER, mail.send raising, the template blowing up, or the admin lookup failing because the DB is unreachable — all still return the normal thank-you page with the record safely on disk. Verified for all four. A missing admin list does not suppress the requester's confirmation.
Storage
One JSON document per submission in ENROLLMENT_DIR, named <YYYYmmdd-HHMMSS>-<8 hex>.json — time-ordered so a directory listing sorts chronologically, random suffix so two submissions in the same second cannot collide. The stem is the submission id and the only thing the admin URLs accept.
_ID_REguards every filesystem access. Ids are validated against^\d{8}-\d{6}-[0-9a-f]{8}$before being joined to a path, so a crafted id (../../etc/passwd) can never escape the directory — verified.- Writes are atomic (
tempfilein the same dir →os.replace), so a crash mid-write cannot leave truncated JSON that would break the admin list for every other submission. load_all()skips a corrupt file with a log line rather than failing the whole page.- Customer answers are immutable after submission.
update_office()merges only the office block + status, so the file stays a faithful record of what was actually requested.
Public page hardening (same posture as rule 74)
Login-free, so: CSRF-protected form, @limiter.limit('5 per hour') on POST only, honeypot field (website, CSS-hidden — a bot that fills it gets a 200 and no file), submit button disabled on first click, noindex meta, and a standalone template with no authenticated nav. Validation requires a project name, a requester, at least one person with both a name and an email (a half-filled row cannot be set up, so it must not pass as one), and no duplicate email addresses; on failure it re-renders with the customer's input intact — including their ticked boxes, folded per-person into the seed_people payload — and returns 400.
Admin
/enrollment/admin (admin-only, linked from the Admin nav dropdown in both layouts). List → detail → office-use fields (Receive Date / Program By / Date email invitation) + status (new / in_progress / completed). GET /admin/<id>.json downloads the raw file; GET /admin/export.csv emits one row per person, not per submission — that is the unit of work when actually creating the accounts. Task cells a person's role cannot have export as n/a, distinct from an unticked ''.