53 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: June 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)
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
1. Project Overview
JQC (Janitorial Quality Control) is a production-grade, full-stack web application that manages:
- Janitorial service contracts organised as Contracts (Projects) → Facilities → Areas
- Inspection execution against configurable templates with dynamic form builder
- Issue tracking with SLA enforcement, follower subscriptions, and verification workflow
- Customer portal with scoped facility visibility and invitation-based onboarding
- Notification system (in-app + email) driven by an admin-controlled matrix
- Reports — on-demand PDF/CSV scorecards and scheduled email digests
- Audit trail — immutable log of every create/update/delete action
- 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
│ ├── templates/
│ │ └── 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/
│ └── phase23_support_tickets.py ← HEAD
└── ...
4. Environment & Configuration
Required Environment Variables
| Variable | Notes |
|---|---|
SECRET_KEY |
Flask secret — no fallback; startup fails if absent |
DATABASE_URL |
e.g. mysql+pymysql://user:pass@localhost/jqc |
MAIL_SERVER |
SMTP hostname |
MAIL_USERNAME |
SMTP login |
MAIL_PASSWORD |
SMTP password |
MAIL_PORT |
465 (SSL) or 587 (STARTTLS) — auto-selects flags |
APP_BASE_URL |
Full URL for email links |
MAIL_DEFAULT_SENDER |
From address |
DIGEST_SECRET |
Authenticates all cron endpoints |
REDIS_URL |
Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
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. |
Email SSL Auto-Detection
MAIL_USE_SSL = _mail_port == 465
MAIL_USE_TLS = not MAIL_USE_SSL
Critical: Never set both to True — Flask-Mail breaks silently.
File Uploads
UPLOAD_FOLDER=app/static/uploads/MAX_CONTENT_LENGTH= 50 MB- Allowed:
png,jpg,jpeg,gif
5. Database Models
User
users: id, username (unique, indexed), full_name, email (unique, indexed),
password_hash, role (ENUM), created_at, active,
password_set, set_password_token (indexed), set_password_token_expires
Role ENUM: admin, director, inspector, project_manager, customer
Key property: display_name → full_name.strip() or falls back to username.
Facility / Area
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK)
areas: id, facility_id (FK), name, area_type
area_type choices: restroom, lobby, hallway, office, kitchen, storage, floor, outdoor, other
Project / CustomerAssignment
projects: id, name, description, project_manager_id, active, created_at
customer_assignments: id, user_id, project_id, facility_id (nullable)
UniqueConstraint(user_id, project_id, facility_id)
inspector_assignments: id, user_id, project_id, created_at
UniqueConstraint(user_id, project_id, name='uq_inspector_project')
ForeignKey user_id → users(id) ON DELETE CASCADE
ForeignKey project_id → projects(id) ON DELETE CASCADE
Inspection
inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date,
overall_score, status (in_progress/completed/flagged), notes, form_data (JSON),
completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note,
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
mobile_local_id: UUID string generated on the iPad. Used for idempotency — if a submission arrives twice (network retry), the server returns the existing record without creating a duplicate. Set NULL for all web-created inspections.
Score rule: Items with score = 0 mean "unanswered" — excluded from calculation entirely.
Issue
issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity (low/medium/high/critical),
description, photo_path VARCHAR(255), status (open/in_progress/resolved/pending_verification),
assigned_to, reported_by (nullable FK → users, SET NULL on delete),
reported_at, resolved_at, result_notes, result_photos (JSON),
mobile_photo_paths (JSON), ← Phase 19
verified_by, verified_at, verification_note, sla_notified,
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
Photo columns — three distinct fields with different semantics:
| Column | Type | Populated by | Displayed as |
|---|---|---|---|
photo_path |
VARCHAR(255) |
Web form upload OR first iPad photo | "Photo Evidence" (primary) |
mobile_photo_paths |
JSON (list[str]) |
iPad PATCH /issues/<id>/photos — extra evidence photos |
"Photo Evidence" (additional) |
result_photos |
JSON (list[str]) |
Web update form file upload — resolution photos | "Resolution Details" |
Rule: Never write iPad evidence photos into result_photos. They belong in mobile_photo_paths so they appear under "Photo Evidence" on the web, not "Resolution Details".
reported_by: Added in phase18. Set at creation time to the user who filed the issue. Nullable for backward compatibility. Used by GET /api/v1/issues to return issues the inspector created but hasn't been assigned yet.
Notification / NotificationPreference
notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
inspection_id, event_type VARCHAR(50) NULL, digest_pending
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
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.
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
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, registered_at
UniqueConstraint(user_id, device_id)
6. Role & Permission Matrix
| Area | admin | director | project_manager | inspector | customer |
|---|---|---|---|---|---|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
| Users | ✅ | ✅ | ❌ | ❌ | ❌ |
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
| Facilities | ✅ | ✅ | ✅ | read | scoped |
| Contracts | ✅ | ✅ | ✅ | read | scoped |
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ 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')
@customer_required # role == 'customer' only
7. Blueprint Prefixes & Route Inventory
| Blueprint | Prefix | Notable routes |
|---|---|---|
auth |
/auth |
/login, /logout, /profile, /users/*, /notification-matrix |
dashboard |
/ |
GET /, /facility-trend (AJAX) |
facilities |
/facilities |
CRUD + area management |
projects |
/projects |
CRUD + customer assignment management |
customers |
/customers |
list, invite, set-password, manage, import CSV |
inspections |
/inspections |
list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
templates |
/templates |
list, create, edit, delete, form editor, preview |
issues |
/issues |
list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign |
notifications |
/notifications |
list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
audit |
/audit |
list (admin only), view, purge |
reports |
/reports |
index, facility report, scorecard, CSV/PDF export |
scheduled_reports |
/scheduled-reports |
CRUD + manual trigger |
support |
/support |
GET /chat, POST /chat/message (AJAX→Groq), POST /tickets, GET /my-tickets, GET/POST /my-tickets/<id>, GET /admin/tickets, GET/POST /admin/tickets/<id> |
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.
scope.py
get_customer_scope(user) — returns list[int] facility IDs for customers, None for non-customers.
get_inspector_scope(user) — returns list[int] facility IDs for inspectors (empty list = no assignments = no access), None for non-inspectors. Derived from InspectorAssignment rows → project → active facilities.
forms.py
All WTForms classes. AreaForm.area_type includes floor. UserForm excludes customer role.
notifications.py
notify(), notify_by_matrix(), notify_customers_for_facility() — all email sent in background thread. notify() stores event_type on the Notification record (phase17+). flag_followup route calls notify() for the original inspector.
sla.py
sla_status(issue) → 'ok' | 'at_risk' | 'breached' | None (resolved).
pdf_export.py
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
9. Mobile API (Phase 7 / Phase A–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 |
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.
Issue API — _issue_payload() fields
{
'id', 'status', 'severity', 'description', 'assigned_to',
'facility_id', 'facility_name', 'reported_at', 'resolved_at',
'mobile_local_id',
'photo_path', # primary evidence photo (first iPad photo or web upload)
'mobile_photo_paths', # extra evidence photos from iPad (list)
'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)
}
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'
Cron Endpoints (all require token=DIGEST_SECRET)
| Endpoint | Purpose | Schedule |
|---|---|---|
POST /notifications/send-digest |
Digest email delivery | 0 7 * * * |
POST /notifications/check-sla |
SLA breach/at-risk alerts | */30 * * * * |
POST /notifications/cleanup-tokens |
Purge expired API tokens | 0 3 * * * |
12. SLA Engine
| Severity | Window | At-Risk |
|---|---|---|
| critical | 4h | 3h |
| high | 24h | 18h |
| medium | 72h | 54h |
| low | 168h | 126h |
issue.sla_notified prevents duplicate cron notifications.
13. Audit Trail
- Admin-only at
/audit/— director is excluded - Actions:
CREATE,UPDATE,DELETE,LOGIN,LOGOUT,EXPORT - Mobile API routes call
log_action()for all create/update operations - Immutable — never updated or deleted through the application
14. PDF Export
ReportLab — app/utils/pdf_export.py. 12-column grid must be preserved — do not collapse in print/PDF.
15. Scheduled Reports
Types: summary, facility, issues. Frequencies: daily, weekly, monthly.
Cron: POST /scheduled-reports/run?secret=<DIGEST_SECRET>
16. Rate Limiting
limiter = Limiter(
key_func = get_remote_address,
default_limits = [],
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
)
Production: Set REDIS_URL=redis://127.0.0.1:6379/0.
17. Alembic Migration Chain
phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix
→ phase9_user_full_name → phase10_customer_password_setup → phase11_director_role
→ phase12_performance_indexes → phase_b_mobile_local_id
→ phase13_issue_facility → phase14_facility_created_at
→ phase15_audit_log_indexes → phase16_notifications_columns
→ phase17_notification_event_type
→ phase18_issue_reported_by
→ phase19_issue_mobile_photos
→ phase20_inspector_assignments
→ phase21_template_active
→ phase21_performance_indexes
→ phase22_comment_visibility
→ phase23_support_tickets ← HEAD
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
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
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.
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).
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.
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 date_from, date_to, inspector_id query params matching the HTML report page. Logs an EXPORT audit action. Uses openpyxl.
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. - Chat history kept client-side in
let history = [], sent with each AJAXPOST /support/chat/message. Server caps at last 20 turns. - If
GROQ_API_KEYis absent, input is disabled and a fallback "Submit to Support" link is shown. - "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
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"
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 email sender and link domain are derived from request.host_url |
_send_invite_email(user, token, base_url=None) in customers.py accepts an optional base_url. Both call sites (invite and resend_invite) pass request.host_url. Inside the function, effective_base is built from that value (falling back to APP_BASE_URL); setup_link uses effective_base; sender is noreply@<netloc> parsed from effective_base. The SMTP server and credentials are unchanged — only the From address and link URL vary per domain. |
| 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. |
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