Updated documents
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -279,495 +275,344 @@ 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)
|
||||
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)
|
||||
@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 1–3 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 1–3 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
|
||||
@@ -7,13 +7,13 @@ A production-grade web application for managing janitorial service contracts, fa
|
||||
## Features
|
||||
|
||||
- **Inspection Management** — Execute structured inspections against configurable templates with a drag-and-drop form builder supporting ratings, pass/fail, photos, signatures, and free-form fields
|
||||
- **Issue Tracking** — Full lifecycle management (open → in-progress → pending verification → resolved) with SLA enforcement, follower subscriptions, and resolution photo uploads
|
||||
- **Customer Portal** — Scoped facility visibility for client accounts with invitation-based onboarding (email link, 72-hour token)
|
||||
- **Notification System** — In-app + email notifications driven by an admin-controlled routing matrix; per-user preferences and digest mode
|
||||
- **Issue Tracking** — Full lifecycle management (open → in-progress → pending verification → resolved) with SLA enforcement, follower subscriptions, resolution photo uploads, and inline quick-assign from the issues list
|
||||
- **Customer Portal** — Scoped facility visibility for client accounts with invitation-based onboarding (email link, 72-hour token); expired invitation warnings surface on the admin dashboard
|
||||
- **Notification System** — In-app + email notifications driven by an admin-controlled routing matrix; per-user preferences including digest mode and a one-click "Pause All Emails" toggle
|
||||
- **Reports** — On-demand PDF/CSV scorecards and scheduled recurring email reports (daily/weekly/monthly)
|
||||
- **Audit Trail** — Immutable log of every create, update, and delete action with actor and IP capture
|
||||
- **Mobile API** — JWT-authenticated REST API for the companion React Native / Expo mobile application
|
||||
- **Project Hierarchy** — Facilities grouped into Projects with optional Project Manager assignment and per-project customer access control
|
||||
- **Mobile API** — JWT-authenticated REST API (Phase 7) for the companion React Native / Expo mobile application; rate-limited login and refresh endpoints
|
||||
- **Contract Hierarchy** — Facilities grouped into Contracts (internally "Projects") with optional Contract Manager assignment and per-contract customer access control
|
||||
|
||||
---
|
||||
|
||||
@@ -26,6 +26,7 @@ A production-grade web application for managing janitorial service contracts, fa
|
||||
| ORM / Migrations | SQLAlchemy + Alembic |
|
||||
| Auth (web) | Flask-Login + Flask-WTF (CSRF) |
|
||||
| Auth (API) | JWT + opaque refresh tokens |
|
||||
| Rate limiting | Flask-Limiter |
|
||||
| Email | Flask-Mail (SMTP) |
|
||||
| PDF | ReportLab |
|
||||
| Frontend | Bootstrap 5, Chart.js, Jinja2 |
|
||||
@@ -37,7 +38,7 @@ A production-grade web application for managing janitorial service contracts, fa
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- MySQL 8.x
|
||||
- MySQL 5.7+ (or 8.0+)
|
||||
- A configured SMTP server (port 465 or 587)
|
||||
|
||||
---
|
||||
@@ -89,7 +90,7 @@ APP_BASE_URL=https://your-domain.com
|
||||
DIGEST_SECRET=<random-secret-for-cron-auth>
|
||||
```
|
||||
|
||||
> **Email SSL:** Port 465 uses implicit SSL (`MAIL_USE_SSL=True`). Port 587 uses STARTTLS (`MAIL_USE_TLS=True`). The application auto-detects based on `MAIL_PORT` — never set both flags to True.
|
||||
> **Email SSL:** Port 465 uses implicit SSL; port 587 uses STARTTLS. The application auto-detects based on `MAIL_PORT` — never set both flags to True.
|
||||
|
||||
### 5. Run database migrations
|
||||
|
||||
@@ -117,6 +118,8 @@ gunicorn -c gunicorn_config.py wsgi:app
|
||||
|
||||
The included `gunicorn_config.py` binds to `127.0.0.1:8000` with sync workers. Log files are written to `/home/jqc/logs/`.
|
||||
|
||||
> **Rate limiting note:** Flask-Limiter uses in-process memory storage by default. With multiple Gunicorn workers each process maintains its own counter. For accurate shared-state enforcement across workers, set `storage_uri = 'redis://localhost:6379'` in `app/__init__.py` and install Redis.
|
||||
|
||||
### Nginx (recommended configuration)
|
||||
|
||||
```nginx
|
||||
@@ -163,31 +166,24 @@ WantedBy=multi-user.target
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
Two background tasks require scheduled execution:
|
||||
|
||||
### SLA Alerts
|
||||
|
||||
Checks all open issues against their SLA window and dispatches notifications:
|
||||
Four background tasks require scheduled execution. All endpoints that require a token use `DIGEST_SECRET`.
|
||||
|
||||
```bash
|
||||
# Run every 30 minutes
|
||||
*/30 * * * * /home/jqc/venv/bin/python -c "
|
||||
from app import create_app
|
||||
from app.utils.sla import send_sla_alerts
|
||||
app = create_app('production')
|
||||
with app.app_context():
|
||||
send_sla_alerts()
|
||||
"
|
||||
```
|
||||
# SLA alerts — every 30 minutes
|
||||
*/30 * * * * curl -s -X POST "https://your-domain.com/notifications/check-sla" \
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
|
||||
### Digest Emails and Scheduled Reports
|
||||
# Daily digest emails — 7:00 AM
|
||||
0 7 * * * curl -s -X POST "https://your-domain.com/notifications/send-digest" \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||||
|
||||
```bash
|
||||
# Daily digest — run at 7:00 AM
|
||||
0 7 * * * curl -s -X POST "https://your-domain.com/notifications/send-digest?frequency=daily&secret=YOUR_DIGEST_SECRET"
|
||||
# Expired/revoked API token cleanup — 3:00 AM
|
||||
0 3 * * * curl -s -X POST "https://your-domain.com/notifications/cleanup-tokens" \
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
|
||||
# Scheduled reports
|
||||
0 8 * * * curl -s -X POST "https://your-domain.com/scheduled-reports/run?secret=YOUR_DIGEST_SECRET"
|
||||
# Scheduled report delivery — 8:00 AM
|
||||
0 8 * * * curl -s -X POST "https://your-domain.com/scheduled-reports/run" \
|
||||
-d "secret=YOUR_DIGEST_SECRET"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -198,7 +194,7 @@ with app.app_context():
|
||||
|---|---|
|
||||
| **admin** | Full access to all features including Audit Trail and Notification Matrix |
|
||||
| **director** | Broad access equivalent to admin, excluding Audit Trail and Notification Matrix |
|
||||
| **project_manager** | Manages projects, facilities, and reports; cannot manage users or system settings |
|
||||
| **project_manager** | Manages contracts, facilities, and reports; cannot manage users or system settings |
|
||||
| **inspector** | Executes inspections and manages assigned issues |
|
||||
| **customer** | Read-only portal scoped to assigned facilities; receives notifications on their facilities |
|
||||
|
||||
@@ -218,11 +214,13 @@ flask shell
|
||||
|
||||
## Customer Onboarding
|
||||
|
||||
1. Admin navigates to **Customers → Invite Customer**
|
||||
2. Enter the customer's name and email address
|
||||
3. The system auto-generates a username and sends an invitation email with a 72-hour setup link
|
||||
4. Customer clicks the link, sets their password, and gains access to their scoped portal
|
||||
5. Admin assigns the customer to one or more Projects/Facilities via **Customers → Manage Assignments**
|
||||
1. Admin navigates to **Customers → New Customer**
|
||||
2. Enter the customer's name and email — username is auto-generated
|
||||
3. The system sends an invitation email with a 72-hour setup link
|
||||
4. Customer sets their password via the link and gains access to their scoped portal
|
||||
5. Admin assigns the customer to Contracts/Facilities via **Customers → Manage**
|
||||
|
||||
> Expired invitations (token past 72 hours, password never set) are surfaced as a warning banner on the Customers page with inline **Resend** buttons.
|
||||
|
||||
---
|
||||
|
||||
@@ -232,15 +230,15 @@ The REST API is available at `/api/v1/` and uses JWT Bearer token authentication
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/auth/login` | Login; returns access + refresh tokens |
|
||||
| POST | `/api/v1/auth/refresh` | Rotate refresh token; returns new access token |
|
||||
| POST | `/api/v1/auth/logout` | Revoke refresh token |
|
||||
| GET | `/api/v1/auth/me` | Return current user profile |
|
||||
| POST | `/api/v1/devices/register` | Register APNs device token for push notifications |
|
||||
| Method | Endpoint | Rate Limit | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/auth/login` | 10/min | Login; returns access + refresh tokens |
|
||||
| POST | `/api/v1/auth/refresh` | 30/min | Rotate refresh token; returns new access token |
|
||||
| POST | `/api/v1/auth/logout` | — | Revoke refresh token |
|
||||
| GET | `/api/v1/auth/me` | — | Return current user profile |
|
||||
| POST | `/api/v1/devices/register` | — | Register APNs device token |
|
||||
|
||||
Access tokens expire after 60 minutes. Refresh tokens are valid for 30 days and rotate on every use.
|
||||
Access tokens expire after 60 minutes. Refresh tokens are valid for 30 days and rotate on every use. Expired and revoked tokens are cleaned up automatically on each login and via a nightly cron job.
|
||||
|
||||
---
|
||||
|
||||
@@ -257,7 +255,10 @@ flask db migrate -m "description of change"
|
||||
flask db downgrade
|
||||
```
|
||||
|
||||
> **MySQL ENUM changes require three steps:** expand the ENUM to include both values, migrate existing data, then contract the ENUM. See `Claude.md` for the full protocol.
|
||||
### MySQL Compatibility Notes
|
||||
|
||||
- **ENUM changes** require three steps: expand → migrate data → contract. Never skip steps.
|
||||
- **`CREATE INDEX IF NOT EXISTS`** is not supported on MySQL < 8.0.1. Use `information_schema.statistics` existence checks instead — see `phase12_performance_indexes.py` for the reusable `_index_exists()` helper pattern.
|
||||
|
||||
---
|
||||
|
||||
@@ -283,7 +284,7 @@ flask db downgrade
|
||||
| `MAIL_PASSWORD` | — | SMTP password |
|
||||
| `MAIL_DEFAULT_SENDER` | `noreply@janitorialqc.local` | From address |
|
||||
| `APP_BASE_URL` | `""` | Base URL for links in emails |
|
||||
| `DIGEST_SECRET` | — | Token for authenticating cron requests |
|
||||
| `DIGEST_SECRET` | — | Authenticates all cron endpoints |
|
||||
| `MAX_CONTENT_LENGTH` | `50MB` | Maximum upload size per request |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user