Files
LT_Janitorial_Quality_Control/CLAUDE.md
T

773 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:** April 2026 (Phase 11 complete)
---
## Table of Contents
1. [Project Overview](#1-project-overview)
2. [Tech Stack](#2-tech-stack)
3. [Repository Layout](#3-repository-layout)
4. [Environment & Configuration](#4-environment--configuration)
5. [Database Models](#5-database-models)
6. [Role & Permission Matrix](#6-role--permission-matrix)
7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory)
8. [Utility Modules](#8-utility-modules)
9. [Mobile API (Phase 7)](#9-mobile-api-phase-7)
10. [Notification System](#10-notification-system)
11. [SLA Engine](#11-sla-engine)
12. [Audit Trail](#12-audit-trail)
13. [PDF Export](#13-pdf-export)
14. [Scheduled Reports](#14-scheduled-reports)
15. [Alembic Migration Chain](#15-alembic-migration-chain)
16. [Frontend Conventions](#16-frontend-conventions)
17. [Infrastructure](#17-infrastructure)
18. [Known Constraints & Hard Rules](#18-known-constraints--hard-rules)
19. [Change Philosophy](#19-change-philosophy)
---
## 1. Project Overview
**JQC (Janitorial Quality Control)** is a production-grade, full-stack web application that manages:
- Janitorial service contracts organised as **Projects → Facilities → Areas**
- **Inspection** execution against configurable templates with dynamic form builder
- **Issue** tracking with SLA enforcement, follower subscriptions, and verification workflow
- **Customer portal** with scoped facility visibility and invitation-based onboarding
- **Notification** system (in-app + email) driven by an admin-controlled matrix
- **Reports** — on-demand PDF/CSV scorecards and scheduled email digests
- **Audit trail** — immutable log of every create/update/delete action
- **Mobile API** — JWT-authenticated REST layer (Phase 7) for a React Native / Expo mobile app
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) |
| Migrations | Flask-Migrate / Alembic |
| Email | Flask-Mail (SMTP, background threading) |
| PDF generation | ReportLab |
| Forms | WTForms + Flask-WTF |
| Templating | Jinja2 |
| Frontend | Bootstrap 5, Chart.js, vanilla JS |
| Server | Gunicorn (sync workers) behind Nginx |
| OS | Ubuntu Linux |
| Mobile client | React Native + Expo (Expo Go for dev; cloud Mac for iOS builds) |
| Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) |
---
## 3. Repository Layout
```
lt_janitorial_quality_control/
├── app/
│ ├── __init__.py # Application factory — create_app()
│ ├── add_form_schema.py # One-off migration helper (safe re-run)
│ ├── api/ # Mobile REST API (Phase 7)
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*
│ │ ├── decorators.py # @jwt_required
│ │ ├── errors.py # JSON error helpers + error handler registration
│ │ └── jwt_utils.py # generate_access_token()
│ ├── models/
│ │ ├── __init__.py # Re-exports all models
│ │ ├── api_token.py # RefreshToken, DeviceToken
│ │ ├── audit.py # AuditLog
│ │ ├── facility.py # Facility, Area
│ │ ├── inspection.py # InspectionTemplate, ChecklistItem, Inspection, InspectionResult
│ │ ├── issue.py # Issue, IssueComment, IssueFollower
│ │ ├── notification.py # Notification, NotificationPreference + event constants
│ │ ├── notification_matrix.py # NotificationMatrix + MATRIX_DEFAULTS, MATRIX_EVENTS
│ │ ├── project.py # Project, CustomerAssignment
│ │ ├── scheduled_report.py # ScheduledReport
│ │ └── user.py # User (with password-setup workflow)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── audit.py # /audit/*
│ │ ├── auth.py # /auth/* (users, login, notification matrix)
│ │ ├── customers.py # /customers/*
│ │ ├── dashboard.py # /
│ │ ├── facilities.py # /facilities/*
│ │ ├── inspections.py # /inspections/*
│ │ ├── issues.py # /issues/*
│ │ ├── notifications.py # /notifications/*
│ │ ├── projects.py # /projects/*
│ │ ├── reports.py # /reports/*
│ │ ├── scheduled_reports.py # /scheduled-reports/*
│ │ └── templates.py # /templates/*
│ ├── static/
│ │ ├── css/ipad_responsive.css
│ │ └── uploads/ # User-uploaded photos (gitignored)
│ ├── templates/ # Jinja2 templates (mirrors routes/ structure)
│ │ ├── base.html # Master layout (navbar, sidebar, flash messages)
│ │ ├── _sla_badge.html # Reusable SLA status badge partial
│ │ └── ...
│ └── utils/
│ ├── __init__.py
│ ├── audit.py # log_action() + ACTION_* constants
│ ├── decorators.py # @admin_required, @supervisor_required, @project_manager_required, @customer_required
│ ├── forms.py # All WTForms form classes
│ ├── notifications.py # notify(), notify_by_matrix(), send_pending_digests()
│ ├── pdf_export.py # generate_inspection_pdf(), scorecard PDF
│ ├── scope.py # get_customer_scope() — facility access for customers
│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts()
│ └── time_utils.py # now_eastern()
├── migrations/
│ └── versions/
│ ├── phase1_projects_roles.py
│ ├── phase6_features.py
│ ├── phase7_mobile_api.py
│ ├── phase8_notification_matrix.py
│ ├── phase9_user_full_name.py
│ ├── phase10_customer_password_setup.py
│ └── phase11_director_role.py # HEAD
├── config.py # Config, DevelopmentConfig, ProductionConfig
├── gunicorn_config.py # bind, workers, log paths
├── requirements.txt
├── run.py # Development entry point
└── wsgi.py # Gunicorn entry point
```
---
## 4. Environment & Configuration
### Required Environment Variables
| Variable | Notes |
|---|---|
| `SECRET_KEY` | Flask secret — no fallback; startup fails if absent |
| `DATABASE_URL` | Full SQLAlchemy URI, 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 `MAIL_USE_SSL`/`MAIL_USE_TLS` |
| `APP_BASE_URL` | Full URL prefix for email links, e.g. `https://jqc.example.com` |
| `MAIL_DEFAULT_SENDER` | From address |
| `DIGEST_SECRET` | Token used by cron to authenticate `/notifications/send-digest` |
### Email SSL Auto-Detection
```python
# config.py — port 465 → implicit SSL; port 587 → STARTTLS
MAIL_USE_SSL = _mail_port == 465
MAIL_USE_TLS = not MAIL_USE_SSL
```
> **Critical:** Never set both flags to `True` — Flask-Mail breaks silently.
### File Uploads
- `UPLOAD_FOLDER` = `app/static/uploads/`
- `MAX_CONTENT_LENGTH` = 50 MB (Flask-side limit; Nginx must also be configured)
- Allowed extensions: `png`, `jpg`, `jpeg`, `gif`
- Subfolders: `inspection_photos/`, `issue_photos/`
### Session Cookies
- `SESSION_COOKIE_SECURE = True` in production (HTTP only for dev)
- `SESSION_COOKIE_HTTPONLY = True`
- `SESSION_COOKIE_SAMESITE = 'Lax'`
- Session lifetime: 24 hours
---
## 5. Database Models
### User
```
users
├── id, username (unique), full_name, email (unique)
├── password_hash, role (ENUM), created_at, active
├── password_set (Bool) — False until customer completes setup flow
├── set_password_token (str/64) — time-limited invitation token
└── set_password_token_expires — Eastern datetime
```
**Role ENUM (current):** `admin`, `director`, `inspector`, `project_manager`, `customer`
> `supervisor` was renamed to `director` in Phase 11. The ENUM no longer contains `supervisor`.
**Key methods:**
- `display_name``full_name.strip()` or falls back to `username`
- `generate_set_password_token(expires_hours=72)` → creates 64-hex token + expiry
- `verify_set_password_token(token)` → returns User or None
### Facility / Area
```
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK→projects)
areas: id, facility_id (FK), name, area_type
```
### Project / CustomerAssignment
```
projects: id, name, description, project_manager_id (FK→users), active, created_at
customer_assignments: id, user_id, project_id, facility_id (nullable)
└── UniqueConstraint(user_id, project_id, facility_id)
```
`facility_id = NULL` means the customer has access to all active facilities in the project.
### InspectionTemplate / ChecklistItem / Inspection / InspectionResult
```
inspection_templates: id, name, description, frequency, created_by, created_at, form_schema (JSON)
checklist_items: id, template_id, category, item_description, scoring_type, weight, requires_photo, display_order
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
inspection_results: id, inspection_id, checklist_item_id, score, passed, comments, photo_path
```
**Scoring rule:** Items with `score = 0` mean "unanswered" and are excluded from score calculation entirely.
**Follow-up workflow:** `parent_inspection_id` links re-inspections back to the original.
### Issue / IssueComment / IssueFollower
```
issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical),
description, photo_path, status (open/in_progress/resolved/pending_verification),
assigned_to (FK→users), reported_at, resolved_at, result_notes, result_photos (JSON),
verified_by, verified_at, verification_note, sla_notified (None/'at_risk'/'breached')
issue_comments: id, issue_id, user_id, status_at_time, body, created_at
issue_followers: id, issue_id, user_id, created_at — UniqueConstraint(issue_id, user_id)
```
**Issue status flow:** `open``in_progress``pending_verification``resolved`
### Notification / NotificationPreference
```
notifications: id, user_id, title, body, link, is_read, created_at, issue_id (nullable), event_type
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
```
### NotificationMatrix
```
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON text)
└── UniqueConstraint(event_type, role_key)
```
### AuditLog
```
audit_logs: id, user_id (FK nullable on delete SET NULL), 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)
```
### ScheduledReport
```
scheduled_reports: id, name, report_type (summary/facility/issues), frequency (daily/weekly/monthly),
facility_id (nullable), recipients (JSON), include_pdf, include_csv,
active, created_by, created_at, last_sent_at, next_send_at
```
---
## 6. Role & Permission Matrix
| Route Area | admin | director | project_manager | inspector | customer |
|---|---|---|---|---|---|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
| Users (`/auth/users`) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Customers (`/customers`) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Facilities | ✅ | ✅ | ✅ | ✅ read | ✅ scoped |
| Projects | ✅ | ✅ | ✅ | ✅ read | ✅ scoped |
| Templates (create/edit) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | ✅ read |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ read |
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
| Reports (export) | ✅ | ✅ | ✅ | ✅ | ✅ scoped |
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ (TBD) |
### Decorator Map
```python
@admin_required # role == 'admin' only
@supervisor_required # role in ('admin', 'director') — intentionally kept as-is (Phase 11)
@project_manager_required # role in ('admin', 'director', 'project_manager')
@customer_required # role == 'customer' only
```
> **Important:** `@supervisor_required` is **not** renamed to `director_required` — that would require touching 30+ route decorators. The function name is a legacy artefact; the access list is correct.
### Customer Scoping
Customer visibility is derived from `CustomerAssignment` rows via `get_customer_scope(user)`:
- Returns `list[int]` of facility IDs, or `None` for non-customer roles
- `facility_id = NULL` assignment → access to all active facilities in that project
- All customer-facing queries must filter by `facility_id.in_(scope)` or `area.facility_id.in_(scope)`
---
## 7. Blueprint Prefixes & Route Inventory
| Blueprint | Prefix | Key Routes |
|---|---|---|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users`, `/users/new`, `/users/<id>/edit`, `/users/<id>/delete`, `/users/<id>/toggle-active`, `/notification-matrix` |
| `dashboard` | `/` | `GET /` |
| `facilities` | `/facilities` | CRUD + `/facilities/<id>/areas` |
| `projects` | `/projects` | CRUD + assignment management |
| `customers` | `/customers` | list, create, edit, delete, manage assignments, invite, set-password |
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag issue |
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
| `issues` | `/issues` | list, view, create, update, assign, verify, comment, follow/unfollow, verification queue |
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron) |
| `audit` | `/audit` | list (admin-only), view |
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger |
| `api` (parent) | `/api/v1` | — |
| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` |
### API Blueprint Registration
The mobile API uses a **parent + child** pattern:
```python
# app/api/__init__.py
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
def register_api(app):
from app.api.auth import bp as auth_bp
api_bp.register_blueprint(auth_bp)
app.register_blueprint(api_bp)
```
CSRF is exempted **before** registration:
```python
csrf.exempt(api_bp)
register_api(app)
```
---
## 8. Utility Modules
### `app/utils/time_utils.py`
```python
from app.utils.time_utils import now_eastern
```
Returns a **naive datetime** representing the current US/Eastern wall-clock time (adjusts automatically for EDT/EST). All `db.Column(db.DateTime, default=now_eastern)` calls use this. Never use `datetime.utcnow()` — it caused incorrect SLA purge cutoffs.
### `app/utils/audit.py`
```python
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT, ACTION_EXPORT
log_action(
action = ACTION_CREATE,
entity_type = 'Facility',
entity_id = facility.id,
entity_label = facility.name,
details = f'address={facility.address}',
)
```
- Must be called **after** `db.session.commit()` to capture the entity's ID
- Never raises — failures are logged but never bubble up to break the primary request
- Snapshots `username` and `user_role` so records survive user deletion
### `app/utils/decorators.py`
Four decorators: `admin_required`, `supervisor_required`, `project_manager_required`, `customer_required`.
All redirect to `dashboard.index` with a flash message on unauthorized access.
### `app/utils/scope.py`
```python
from app.utils.scope import get_customer_scope
facility_ids = get_customer_scope(current_user) # None for non-customers
if facility_ids is not None:
q = q.filter(Inspection.facility_id.in_(facility_ids))
```
### `app/utils/forms.py`
All WTForms classes live here:
- `LoginForm`, `ProfileForm`, `UserForm` (excludes `customer` role — use `/customers`)
- `FacilityForm`, `AreaForm`
- `InspectionTemplateForm`, `ChecklistItemForm`, `StartInspectionForm`
- `IssueForm`, `IssueUpdateForm`
- `ProjectForm`, `ProjectAssignmentForm`
- `CustomerUserForm`, `CustomerAssignmentForm`, `CustomerInviteForm`, `SetPasswordForm`
- `ScheduledReportForm`
### `app/utils/notifications.py`
```python
from app.utils.notifications import notify, notify_by_matrix, notify_customers_for_facility
notify(
recipient = user_obj,
title = 'Issue Updated',
body = 'Status changed to resolved.',
link = '/issues/42',
issue_id = 42,
event_type = EVENT_ISSUE_STATUS,
send_email = True,
)
notify_by_matrix(
event_type = 'inspection_completed',
title = '...',
body = '...',
link = '...',
issue_id = None,
exclude_user_ids = {assignee.id}, # deduplicate
)
```
Email is sent in a background thread so it never blocks the HTTP response.
### `app/utils/pdf_export.py`
- `generate_inspection_pdf(inspection)` — full ReportLab inspection report with 12-column CSS grid preserved in print
- Scorecard generation for facility/project reports
- **Critical:** The 12-column grid layout must not be collapsed to single-column in print/PDF views
### `app/utils/sla.py`
SLA thresholds: `critical=4h`, `high=24h`, `medium=72h`, `low=168h`
At-risk threshold: 75% of window elapsed.
```python
from app.utils.sla import sla_status, sla_deadline, sla_hours_remaining, send_sla_alerts
status = sla_status(issue) # 'ok' | 'at_risk' | 'breached' | None (resolved)
```
`send_sla_alerts()` is called by cron. It deduplicates using `issue.sla_notified` — once `breached` is recorded, no further alerts fire for that issue.
---
## 9. Mobile API (Phase 7)
### Authentication Flow
1. `POST /api/v1/auth/login` → returns `access_token` (JWT, 60 min) + `refresh_token` (opaque hex, 30 days)
2. App stores both in iOS Keychain
3. Every authenticated request sends `Authorization: Bearer <access_token>`
4. When access token expires → `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued)
5. `POST /api/v1/auth/logout` → revokes refresh token
### JWT Details
- Library: PyJWT
- Access token lifetime: 60 minutes (configurable in `jwt_utils.py`)
- Payload: `sub` (user ID), `exp`, `iat`, `role`
- `@jwt_required` decorator extracts and validates the token; sets `g.api_user`
### Refresh Token Storage
- Raw token is **never stored** — only its SHA-256 hex digest (`token_hash`)
- `RefreshToken.verify(raw_token)` hashes and looks up; returns None if expired/revoked
- Token rotation: old row's `revoked = True`, new row inserted
### CSRF Exemption Pattern
```python
# app/__init__.py — order matters
csrf = CSRFProtect() # module-level instance
csrf.init_app(app) # in create_app()
csrf.exempt(api_bp) # BEFORE register_api(app)
register_api(app)
```
> **Never** use per-route decorators (`@csrf.exempt`) — Flask-WTF 1.0+ removed that API.
### Phase 2 Extensions (planned)
Commented stubs in `app/api/__init__.py`:
- `api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos`
---
## 10. Notification System
### Event Types (constants in `app/models/notification.py`)
```
EVENT_INSPECTION_DONE = 'inspection_completed'
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
EVENT_ISSUE_REASSIGNED = 'issue_reassigned'
EVENT_ISSUE_UNASSIGNED = 'issue_unassigned'
EVENT_ISSUE_STATUS = 'issue_status'
EVENT_ISSUE_COMMENT = 'issue_comment'
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
EVENT_ISSUE_FLAGGED = 'issue_flagged'
EVENT_ISSUE_CREATED = 'issue_created'
EVENT_CUSTOMER_ISSUE_UPDATED = 'issue_updated_customer'
EVENT_VERIFICATION_REQUESTED = 'verification_requested'
EVENT_SLA_ALERT = 'sla_alert'
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
```
### Notification Matrix
The `notification_matrix` table controls which roles receive each event type. Admin UI at `/auth/notification-matrix` (admin-only).
- `role_key` values: `admin`, `director`, `inspector`, `project_manager`, `customer`, `custom`
- `custom` role_key stores a JSON list of arbitrary email addresses
- Matrix rows are auto-created from `MATRIX_DEFAULTS` on first access (`get_matrix_row()`)
### Implicit Notifications (always sent, not matrix-controlled)
- Issue **assignee** — always notified on assignment/status changes
- Issue **followers** — always notified on any update
- SLA **assignee + followers** — always notified on SLA events
### Notification Preferences
Users can configure per-event preferences at `/notifications/preferences`:
- `email_enabled` — receive email at all
- `digest_mode` — batch into digest rather than immediate send
- `digest_frequency` — daily/weekly/monthly
### Digest Cron
`POST /notifications/send-digest?frequency=daily&secret=<DIGEST_SECRET>` — triggered by server cron, authenticates via `DIGEST_SECRET`.
### Email Delivery
Emails are dispatched in a background thread:
```python
t = threading.Thread(target=_send_email_thread, args=(...))
t.daemon = True
t.start()
```
Never blocks the HTTP response. Failures are logged but not raised.
---
## 11. SLA Engine
| Severity | Window | At-Risk Trigger |
|---|---|---|
| critical | 4 hours | 3 hours |
| high | 24 hours | 18 hours |
| medium | 72 hours | 54 hours |
| low | 168 hours (7 days) | 126 hours |
- Cron calls `send_sla_alerts()` from within Flask app context
- `Issue.sla_notified` prevents duplicate notifications — tracked per-issue at level (`at_risk` / `breached`)
- `breached` supersedes `at_risk` — user receives two notifications total (once at-risk, once breached)
- SLA does not apply once `issue.status == 'resolved'`
---
## 12. Audit Trail
Covered entities: `User`, `Facility`, `Area`, `Template`, `ChecklistItem`, `Inspection`, `Issue`, `IssueComment`, `Project`, `CustomerAssignment`, `ScheduledReport`, `NotificationMatrix`
Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT`
- Admin-only access: `/audit/` and `/audit/<id>`
- Director role: **excluded** from viewing the Audit Trail
- IP captured from `X-Forwarded-For` (Nginx) or `request.remote_addr`
- `username` and `user_role` are snapshotted at write time — survives user deletion (`ondelete='SET NULL'`)
---
## 13. PDF Export
Uses **ReportLab** directly (not weasyprint or wkhtmltopdf). Located in `app/utils/pdf_export.py`.
- Inspection PDF: full checklist results, scores, photos, notes, follow-up status
- Scorecard PDF: facility/project performance summary with charts
- **12-column grid must be preserved** — do not collapse to single-column in PDF views
Reports also support CSV export (Python `csv` module, streamed as `Response`).
---
## 14. Scheduled Reports
`ScheduledReport` records drive recurring email reports:
- Types: `summary` (KPI digest), `facility` (single facility scorecard), `issues` (open issues list)
- Frequencies: `daily`, `weekly`, `monthly`
- Attachments: optional PDF and/or CSV via `include_pdf` / `include_csv`
- `next_send_at` and `last_sent_at` track execution state
- Cron trigger: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
---
## 15. 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 ← HEAD
```
### MySQL ENUM Change Protocol (3 Steps)
**Always** follow this sequence when modifying an ENUM column — skipping steps causes data loss or migration failures:
```sql
-- Step 1: Expand ENUM to include BOTH old and new values
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
-- Step 2: Migrate existing data
UPDATE users SET role = 'director' WHERE role = 'supervisor';
-- Step 3: Contract ENUM to remove old value
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
```
### Adding New Migrations
```bash
flask db migrate -m "description"
flask db upgrade
```
All migration scripts include existence checks for safe re-runs.
### Deprecated SQLAlchemy Patterns
```python
# WRONG — deprecated in SQLAlchemy 2.x
User.query.get(user_id)
# CORRECT
db.session.get(User, user_id)
```
Also: `filter()` must precede `limit()`. Avoid N+1 query patterns — use eager loading or bulk queries.
---
## 16. Frontend Conventions
### Base Template (`base.html`)
- Bootstrap 5 (CDN)
- Chart.js (CDN, for dashboard charts)
- Navbar includes unread notification badge (injected via `@app.context_processor`)
- `pending_verification_count` badge for admin/director roles
- Jinja2 globals available in all templates: `csrf_token`, `enumerate`, `sla_status`, `sla_deadline`, `sla_hours_remaining`, `SLA_HOURS`
### Forms
- All web forms use Flask-WTF CSRF protection automatically
- Manual forms (without a WTForms object) must include `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">`
- **Never nest `<form>` tags** — browsers silently discard inner forms. Toggle/action forms must be siblings, not children, of edit forms.
### File Uploads in Templates
Photos are served from `app/static/uploads/` via `url_for('static', filename=path)`.
### Real-Time Updates
**SSE is banned.** Server-Sent Events exhausted the Gunicorn sync worker pool in production. All "live" updates use polling (JS `setInterval` + `fetch`).
---
## 17. Infrastructure
### Gunicorn (`gunicorn_config.py`)
```python
bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync" # SSE would require 'gevent' or 'eventlet' — banned
timeout = 30
```
Log paths: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
### Application Logging
Configured in `create_app()`:
- `RotatingFileHandler``logs/jqc.log` (5 × 5 MB)
- `StreamHandler` → stdout (captured by journalctl)
- Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message`
- Applied to both `app.logger` and the root logger
### Nginx
- Reverse proxies to `127.0.0.1:8000`
- Must set `client_max_body_size 50M` to match `MAX_CONTENT_LENGTH`
- Passes `X-Forwarded-For` for IP capture in audit logs
### Deployment Sequence (typical change)
```bash
git pull
pip install -r requirements.txt # if dependencies changed
flask db upgrade # if migrations added
sudo systemctl restart jqc # or equivalent gunicorn restart
```
---
## 18. Known Constraints & Hard Rules
| # | Rule | Rationale |
|---|---|---|
| 1 | **No SSE** | Exhausted Gunicorn sync worker pool in production |
| 2 | **`now_eastern()` always** | `datetime.utcnow()` caused incorrect SLA purge cutoffs |
| 3 | **3-step MySQL ENUM changes** | Skipping steps causes data loss |
| 4 | **Port 465 → SSL, port 587 → STARTTLS** | Both flags True breaks Flask-Mail |
| 5 | **`CSRFProtect` as extension instance, not per-route** | Flask-WTF 1.0+ removed `@csrf_exempt` |
| 6 | **`supervisor_required` decorator name preserved** | Changing it would touch 30+ route decorators |
| 7 | **Score 0 = unanswered, exclude from calculation** | Not the same as a score of zero |
| 8 | **12-column grid in PDF** | Must not collapse in print/PDF views |
| 9 | **No nested `<form>` tags** | Browsers silently discard inner forms |
| 10 | **`log_action()` after `db.session.commit()`** | Entity ID must be committed 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 query loop caused N+1 in `/customers` |
| 14 | **Email sent in background thread** | Never block HTTP response |
| 15 | **Open-redirect guard on login** | `_safe_next()` validates relative URLs only |
---
## 19. Change Philosophy
1. **Surgical, additive patches** — smallest possible change to achieve the goal
2. **Preserve all routes, function names, and variable names** unless explicitly asked to rename
3. **Never remove existing functionality** unless explicitly directed
4. **Log all create/update/delete actions** via `log_action()`
5. **Test migrations with existence checks** so they are safe to re-run
6. **Full file contents for 13 file changes**; zip package with placement map for larger changesets
7. **Explicit deploy instructions** always included (migration steps separated from code changes)
8. **Root cause analysis** on errors — never apply temporary workarounds