Updated documents

This commit is contained in:
2026-04-25 13:14:00 -04:00
parent e3e30f01e9
commit ff0650e1d9
2 changed files with 320 additions and 474 deletions
+276 -431
View File
@@ -2,7 +2,7 @@
> **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)
> **Last reviewed:** April 2026 (Phase 12 complete)
---
@@ -22,11 +22,12 @@
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)
15. [Rate Limiting](#15-rate-limiting)
16. [Alembic Migration Chain](#16-alembic-migration-chain)
17. [Frontend Conventions](#17-frontend-conventions)
18. [Infrastructure](#18-infrastructure)
19. [Known Constraints & Hard Rules](#19-known-constraints--hard-rules)
20. [Change Philosophy](#20-change-philosophy)
---
@@ -34,7 +35,7 @@
**JQC (Janitorial Quality Control)** is a production-grade, full-stack web application that manages:
- Janitorial service contracts organised as **Projects → Facilities → Areas**
- 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
@@ -57,6 +58,7 @@ The application is actively deployed in production and maintained by a single de
| 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 (in-memory storage; swap for Redis in multi-worker) |
| Migrations | Flask-Migrate / Alembic |
| Email | Flask-Mail (SMTP, background threading) |
| PDF generation | ReportLab |
@@ -75,55 +77,53 @@ The application is actively deployed in production and maintained by a single de
```
lt_janitorial_quality_control/
├── app/
│ ├── __init__.py # Application factory — create_app()
│ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager
│ ├── 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/*
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*; rate-limited login/refresh
│ │ ├── 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
│ │ ├── __init__.py
│ │ ├── 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
│ │ ├── notification_matrix.py
│ │ ├── project.py # Project, CustomerAssignment
│ │ ├── scheduled_report.py # ScheduledReport
│ │ └── user.py # User (with password-setup workflow)
│ │ └── user.py # User (password-setup workflow, display_name)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── audit.py # /audit/*
│ │ ├── auth.py # /auth/* (users, login, notification matrix)
│ │ ├── customers.py # /customers/*
│ │ ├── dashboard.py # /
│ │ ├── auth.py # /auth/* — login rate-limited; _safe_next() open-redirect guard
│ │ ├── customers.py # /customers/* — _safe_referrer() helper; expired invitation banner
│ │ ├── dashboard.py # / — single open_issues query (len() not .count())
│ │ ├── facilities.py # /facilities/*
│ │ ├── inspections.py # /inspections/*
│ │ ├── issues.py # /issues/*
│ │ ├── notifications.py # /notifications/*
│ │ ├── inspections.py # /inspections/* — passes now=now_eastern() for stale badge
│ │ ├── issues.py # /issues/* — quick-assign AJAX; facility filter; SLA column
│ │ ├── notifications.py # /notifications/* — send-digest, check-sla, cleanup-tokens
│ │ ├── projects.py # /projects/*
│ │ ├── reports.py # /reports/*
│ │ ├── scheduled_reports.py # /scheduled-reports/*
│ │ ── templates.py # /templates/*
│ │ ── templates.py # /templates/*
│ │ └── audit.py # /audit/*
│ ├── 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
│ │ └── uploads/
│ ├── templates/
│ │ ├── base.html # Active nav tab styling; request.endpoint-based active detection
│ │ ├── _sla_badge.html
│ │ └── ...
│ └── 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
│ ├── forms.py # All WTForms (AreaForm includes 'floor' type)
│ ├── 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
│ ├── pdf_export.py # ReportLab PDF generation
│ ├── scope.py # get_customer_scope()
│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts()
│ └── time_utils.py # now_eastern()
├── migrations/
@@ -134,12 +134,13 @@ lt_janitorial_quality_control/
│ ├── 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
── phase11_director_role.py
│ └── phase12_performance_indexes.py ← HEAD
├── config.py
├── gunicorn_config.py
├── requirements.txt # Includes Flask-Limiter
── run.py
└── wsgi.py
```
---
@@ -151,38 +152,29 @@ lt_janitorial_quality_control/
| 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` |
| `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 `MAIL_USE_SSL`/`MAIL_USE_TLS` |
| `APP_BASE_URL` | Full URL prefix for email links, e.g. `https://jqc.example.com` |
| `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` | Token used by cron to authenticate `/notifications/send-digest` |
| `DIGEST_SECRET` | Authenticates all cron endpoints: `send-digest`, `check-sla`, `cleanup-tokens`, `scheduled-reports/run` |
### 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.
**Critical:** Never set both 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
- `MAX_CONTENT_LENGTH` = 50 MB
- Allowed: `png`, `jpg`, `jpeg`, `gif`
---
@@ -191,85 +183,89 @@ MAIL_USE_TLS = not MAIL_USE_SSL
### 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
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 (current):** `admin`, `director`, `inspector`, `project_manager`, `customer`
> `supervisor` was renamed to `director` in Phase 11. The ENUM no longer contains `supervisor`.
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
**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
**Key property:** `display_name``full_name.strip()` or falls back to `username`. Use this everywhere in templates — never use `.username` for display.
**Expired invitations:** Customer accounts with `password_set=False` and `set_password_token_expires < now_eastern()` are flagged on the `/customers` index with an inline Resend button.
### Facility / Area
```
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK→projects)
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 (FK→users), active, created_at
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)
UniqueConstraint(user_id, project_id, facility_id)
```
`facility_id = NULL` means the customer has access to all active facilities in the project.
**UI terminology:** "Project" is displayed as **"Contract"** throughout the UI. All backend identifiers (`project_id`, class `Project`, routes `projects.*`) are unchanged.
### InspectionTemplate / ChecklistItem / Inspection / InspectionResult
`facility_id = NULL` → access to all active facilities in that project.
### Inspection
```
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
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
```
**Scoring rule:** Items with `score = 0` mean "unanswered" and are excluded from score calculation entirely.
**Indexed columns (Phase 12):** `status`, `facility_id`, `inspector_id`, `inspection_date`
**Follow-up workflow:** `parent_inspection_id` links re-inspections back to the original.
**Score rule:** Items with `score = 0` mean "unanswered" — excluded from calculation entirely.
### Issue / IssueComment / IssueFollower
**Status display:** `completed` renders as **"Submitted"** in the UI. DB value unchanged.
**Stale badge:** In-progress inspections older than 24 hours show a `⚠ Stale` badge. Route passes `now=now_eastern()` to the list template.
### Issue
```
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)
assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON),
verified_by, verified_at, verification_note, sla_notified
```
**Indexed columns (Phase 12):** `status`, `severity`, `assigned_to`, `reported_at`
**Issue status flow:** `open``in_progress``pending_verification``resolved`
**Quick-assign:** `POST /issues/<id>/quick-assign` — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail.
### Notification / NotificationPreference
```
notifications: id, user_id, title, body, link, is_read, created_at, issue_id (nullable), event_type
notifications: id, user_id, title, body, link, is_read, created_at, issue_id, event_type
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
```
**Pause all emails:** Preferences page has a "Pause All Emails" toggle that bulk-disables all email toggles client-side.
### NotificationMatrix
```
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON text)
└── UniqueConstraint(event_type, role_key)
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
UniqueConstraint(event_type, role_key)
```
### AuditLog
```
audit_logs: id, user_id (FK nullable on delete SET NULL), username (snapshot), user_role (snapshot),
audit_logs: id, user_id (nullable, SET NULL on delete), username (snapshot), user_role (snapshot),
action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address
```
@@ -278,496 +274,345 @@ audit_logs: id, user_id (FK nullable on delete SET NULL), username (snapshot), u
```
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)
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
```
**Token cleanup:**
1. **Passive** — on every successful API login, expired/revoked tokens for that user are deleted
2. **Cron**`POST /notifications/cleanup-tokens?token=<DIGEST_SECRET>` deletes all globally
---
## 6. Role & Permission Matrix
| Route Area | admin | director | project_manager | inspector | customer |
| Area | admin | director | project_manager | inspector | customer |
|---|---|---|---|---|---|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
| Users (`/auth/users`) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Users | ✅ | ✅ | ❌ | ❌ | ❌ |
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Customers (`/customers`) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Facilities | ✅ | ✅ | ✅ | read | scoped |
| Projects | ✅ | ✅ | ✅ | read | scoped |
| Templates (create/edit) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | read |
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
| Facilities | ✅ | ✅ | ✅ | read | scoped |
| Contracts | ✅ | ✅ | ✅ | read | scoped |
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | read |
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
| Reports (export) | ✅ | ✅ | ✅ | ✅ | scoped |
| Reports | ✅ | ✅ | ✅ | ✅ | scoped |
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ (TBD) |
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ |
### 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
@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
```
> **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 |
| Blueprint | Prefix | Notable 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 |
| `auth` | `/auth` | `/login` (rate-limited 20/min), `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
| `facilities` | `/facilities` | CRUD + area management |
| `projects` | `/projects` | CRUD + customer assignment management |
| `customers` | `/customers` | list (expired invitation banner), invite, set-password, manage, import CSV |
| `inspections` | `/inspections` | list (stale badge, `now` passed from route), start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect |
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
| `issues` | `/issues` | list, view, create, update, assign, verify, comment, follow/unfollow, verification queue |
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron) |
| `audit` | `/audit` | list (admin-only), view |
| `issues` | `/issues` | list (SLA column, facility filter, quick-assign dropdown), view, create, update, verify, comment, follow/unfollow, verification queue, delete |
| `issues` | `/issues` | `POST /<id>/quick-assign` — JSON AJAX, admin/director only |
| `notifications` | `/notifications` | list, mark-read, mark-all-read, preferences (pause-all toggle), `send-digest` (cron), `check-sla` (cron), `cleanup-tokens` (cron) |
| `audit` | `/audit` | list (admin only), view, purge |
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger |
| `api` (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)
```
| `api` | `/api/v1` | parent blueprint |
| `api_auth` | `/api/v1` | `/auth/login` (10/min), `/auth/refresh` (30/min), `/auth/logout`, `/auth/me`, `/devices/register` |
---
## 8. Utility Modules
### `app/utils/time_utils.py`
### `time_utils.py`
`now_eastern()` — always use this, never `datetime.utcnow()`.
```python
from app.utils.time_utils import now_eastern
```
### `audit.py`
`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`.
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.
### `scope.py`
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff.
### `app/utils/audit.py`
### `forms.py`
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role.
```python
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT, ACTION_EXPORT
### `notifications.py`
`notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread.
log_action(
action = ACTION_CREATE,
entity_type = 'Facility',
entity_id = facility.id,
entity_label = facility.name,
details = f'address={facility.address}',
)
```
### `sla.py`
`sla_status(issue)``'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
`send_sla_alerts()` — called by cron, deduplicates via `issue.sla_notified`.
- 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.
### `pdf_export.py`
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
---
## 9. Mobile API (Phase 7)
### Authentication Flow
### Auth Flow
1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex)
2. Bearer token on every request
3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued)
4. `POST /api/v1/auth/logout` → revokes refresh token
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
### Rate Limits
| Endpoint | Limit |
|---|---|
| `POST /api/v1/auth/login` | 10/min, 3/sec |
| `POST /api/v1/auth/refresh` | 30/min, 5/sec |
| `POST /auth/login` (web) | 20/min, 5/sec |
### 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
### Refresh Token Cleanup
- Raw token never stored — SHA-256 hash only
- Passive: expired/revoked tokens purged per-user on each login
- Cron: `POST /notifications/cleanup-tokens`
### 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)
csrf.exempt(api_bp) # BEFORE register_api(app) — order matters
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`
### Phase 2 (planned)
`api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos` — all stubbed in `app/api/__init__.py`.
---
## 10. Notification System
### Event Types (constants in `app/models/notification.py`)
### Event Constants (`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'
inspection_completed, issue_assigned, issue_reassigned, issue_unassigned,
issue_status, issue_comment, issue_follow_update, issue_flagged, issue_created,
issue_updated_customer, verification_requested, sla_alert,
customer_inspection_completed
```
### Notification Matrix
### Cron Endpoints (all require `token=DIGEST_SECRET`)
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()`)
| 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 * * *` |
### 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.
- Issue **assignee** on assignment/status/comment
- Issue **followers** on any update
- SLA **assignee + followers** on SLA events
---
## 11. SLA Engine
| Severity | Window | At-Risk Trigger |
| Severity | Window | At-Risk |
|---|---|---|
| critical | 4 hours | 3 hours |
| high | 24 hours | 18 hours |
| medium | 72 hours | 54 hours |
| low | 168 hours (7 days) | 126 hours |
| critical | 4h | 3h |
| high | 24h | 18h |
| medium | 72h | 54h |
| low | 168h | 126h |
- 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'`
SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron notifications.
---
## 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'`)
- Admin-only at `/audit/` — director is excluded
- Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT`
- Immutable — never updated or deleted through the application (purge UI exists for old records)
- IP from `X-Forwarded-For` or `request.remote_addr`
---
## 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`).
ReportLab`app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF.
---
## 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>`
Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`.
Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
---
## 15. Alembic Migration Chain
## 15. Rate Limiting
```
phase1_projects_roles
└── phase6_features
└── phase7_mobile_api
└── phase8_notification_matrix
└── phase9_user_full_name
└── phase10_customer_password_setup
└── phase11_director_role ← HEAD
Initialised in `app/__init__.py` as a module-level extension:
```python
limiter = Limiter(
key_func = get_remote_address,
default_limits = [],
storage_uri = 'memory://', # ← swap for 'redis://...' in multi-worker
)
limiter.init_app(app)
```
### MySQL ENUM Change Protocol (3 Steps)
Import in routes: `from app import limiter`, then `@limiter.limit('N per period')`.
**Always** follow this sequence when modifying an ENUM column — skipping steps causes data loss or migration failures:
> **Multi-worker caveat:** `memory://` is per-process. With multiple Gunicorn workers the effective limit is `N × workers`. Use Redis for shared-state enforcement.
---
## 16. 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 ← HEAD
```
### MySQL ENUM Change Protocol (3 steps — always follow)
```sql
-- Step 1: Expand ENUM to include BOTH old and new values
-- 1. Expand to include both values
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
-- Step 2: Migrate existing data
-- 2. Migrate data
UPDATE users SET role = 'director' WHERE role = 'supervisor';
-- Step 3: Contract ENUM to remove old value
-- 3. Contract 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.
### MySQL Compatibility
`CREATE INDEX IF NOT EXISTS` requires MySQL ≥ 8.0.1. For 5.7 compatibility use `information_schema.statistics` existence checks — see `phase12_performance_indexes.py` for the reusable `_index_exists()` pattern.
### Deprecated SQLAlchemy Patterns
```python
# WRONG — deprecated in SQLAlchemy 2.x
User.query.get(user_id)
# WRONG
Model.query.get(id)
Model.query.get_or_404(id)
# CORRECT
db.session.get(User, user_id)
obj = db.session.get(Model, id)
if obj is None: abort(404)
```
Also: `filter()` must precede `limit()`. Avoid N+1 query patterns — use eager loading or bulk queries.
---
## 16. Frontend Conventions
## 17. Frontend Conventions
### Base Template (`base.html`)
### Active Nav Tab
```css
/* base.html <style> block */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255,255,255,0.18);
color: #fff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
```
Active state detected via `request.endpoint.startswith('<blueprint>.')` in each nav `<a>` tag.
- 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`
### 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
- Manual forms: `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">`
- **Never nest `<form>` tags** — browsers silently discard inner 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`).
### Real-Time
**SSE banned.** All "live" updates use polling.
---
## 17. Infrastructure
### Gunicorn (`gunicorn_config.py`)
## 18. Infrastructure
### Gunicorn
```python
bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync" # SSE would require 'gevent' or 'eventlet' — banned
worker_class = "sync"
timeout = 30
```
Log paths: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
Logs: `/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)
- `StreamHandler` → stdout (journalctl)
- Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message`
- Applied to both `app.logger` and the root logger
### Nginx
- `client_max_body_size 50M`
- Passes `X-Forwarded-For`
- 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)
### Recommended Cron Schedule
```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
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"
```
---
## 18. Known Constraints & Hard Rules
## 19. 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 |
| 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 | **`CSRFProtect` as extension instance** | Flask-WTF 1.0+ removed per-route `@csrf_exempt` |
| 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 be committed before audit capture |
| 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 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 |
| 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_next()` in auth.py; `_safe_referrer()` in customers.py |
| 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.1** | Use `information_schema` — see phase12 |
| 17 | **Flask-Limiter `memory://` is per-process** | Multi-worker needs Redis for shared counters |
| 18 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged |
| 19 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only |
| 20 | **`open_issues` count derived from `len()` not `.count()`** | Avoids a second DB round-trip on the dashboard |
---
## 19. Change Philosophy
## 20. 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
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise
3. **Never remove existing functionality** unless explicitly directed
4. **Log all create/update/delete actions** via `log_action()`
5. **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)
5. **Migration existence checks** — all migrations safe to re-run
6. **Full file contents for 13 file changes**; deployment map for larger changesets
7. **Explicit deploy instructions** migration steps separated from code steps
8. **Root cause analysis** on errors — never apply temporary workarounds